diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities.ts index fea770b1d7a..2cff737cda2 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities.ts @@ -19,6 +19,7 @@ import { persistEntityAction } from "./flow-activities/persist-entity-action.js" import { planDashboardRefinementAction } from "./flow-activities/plan-dashboard-refinement-action.js"; import { processAutomaticBrowsingSettingsAction } from "./flow-activities/process-automatic-browsing-settings-action.js"; import { queryEntitiesAction } from "./flow-activities/query-entities-action.js"; +import { readEntityAction } from "./flow-activities/read-entity-action.js"; import { researchEntitiesAction } from "./flow-activities/research-entities-action.js"; import { takeFirstAction } from "./flow-activities/take-first-action.js"; import { webSearchAction } from "./flow-activities/web-search-action.js"; @@ -51,6 +52,7 @@ export const createFlowActionActivities: CreateFlowActivities< persistEntityAction, processAutomaticBrowsingSettingsAction, queryEntitiesAction, + readEntityAction, researchEntitiesAction, takeFirstAction, webSearchAction, diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/filter-action/evaluate-condition.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/filter-action/evaluate-condition.ts index eb0120f48ff..9e3f1a3b2a0 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/filter-action/evaluate-condition.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/filter-action/evaluate-condition.ts @@ -36,7 +36,8 @@ export type ConditionResult = | { status: "success"; matches: boolean } | ConditionError; -const isEmptyValue = (value: unknown): boolean => +/** Whether a value counts as missing: Filter treats it as empty, and Read entity gives no output for it. */ +export const isEmptyValue = (value: unknown): boolean => value === undefined || value === null || value === ""; const ownProperty = (value: unknown, key: string): unknown => diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.test.ts new file mode 100644 index 00000000000..5642cc5e98b --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { storePayload } from "@local/hash-backend-utils/flows/payload-storage"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { StatusCode } from "@local/status"; + +import { readEntityAction } from "./read-entity-action.js"; + +import type { BaseUrl, EntityId, WebId } from "@blockprotocol/type-system"; +import type { FileStorageProvider } from "@local/hash-backend-utils/file-storage"; +import type { QueryEntitiesResponse } from "@local/hash-graph-sdk/entity"; +import type { EntityProperty } from "@local/hash-isomorphic-utils/flows/read-entity"; +import type { + PayloadValue, + StepInput, +} from "@local/hash-isomorphic-utils/flows/types"; + +const workflowId = "workflow"; +const objects = new Map(); +const storageProvider = { + uploadDirect: ({ key, body }: { key: string; body: string }) => { + objects.set(key, body); + return Promise.resolve(); + }, + downloadDirect: ({ key }: { key: string }) => + Promise.resolve(Buffer.from(objects.get(key) ?? "null")), +} as unknown as FileStorageProvider; + +vi.mock("../shared/graph-api-client.js", () => ({ graphApiClient: {} })); +vi.mock("../shared/get-flow-context.js", () => ({ + getFlowContext: () => + Promise.resolve({ + workflowId, + runId: "run", + stepId: "read", + userAuthentication: { actorId: "user" }, + }), +})); +vi.mock( + "@local/hash-backend-utils/flows/payload-storage", + async (importOriginal) => ({ + ...(await importOriginal()), + getStorageProvider: () => storageProvider, + }), +); +vi.mock("@local/hash-graph-sdk/entity", () => ({ queryEntities: vi.fn() })); + +const webId = "00000000-0000-4000-8000-000000000001" as WebId; +const entityIdOf = (index: number) => + `${webId}~00000000-0000-4000-8000-${String(index).padStart(12, "0")}` as EntityId; + +const fieldUrl = "https://example.com/types/property-type/field/" as BaseUrl; + +/** Answers the graph query with the given entities, each with the given value of the property read. */ +const mockEntities = (entities: { entityId: EntityId; value?: unknown }[]) => + vi.mocked(queryEntities).mockResolvedValueOnce({ + entities: entities.map(({ entityId, value }) => ({ + properties: value === undefined ? {} : { [fieldUrl]: value }, + metadata: { recordId: { entityId }, entityTypeIds: [] }, + })), + cursor: null, + } as unknown as QueryEntitiesResponse); + +const run = (params: { + entityId: PayloadValue<"EntityId", false>; + payloadKind: string; +}) => + readEntityAction({ + inputs: [ + { + inputName: "entityId", + payload: { kind: "EntityId", value: params.entityId }, + }, + { + inputName: "property", + payload: { + kind: "EntityProperty", + value: { + kind: "entityProperty", + propertyTypeBaseUrl: fieldUrl, + payloadKind: params.payloadKind, + } as EntityProperty, + }, + }, + ] satisfies StepInput[], + }); + +/** Reads the property of the entity `value` is the value of. */ +const read = (payloadKind: string, value: unknown) => { + mockEntities([{ entityId: entityIdOf(1), value }]); + return run({ entityId: entityIdOf(1), payloadKind }); +}; + +describe("readEntityAction", () => { + beforeEach(() => { + vi.mocked(queryEntities).mockReset(); + }); + + /* A falsy value of each kind, where it has one, so that it isn't mistaken for a missing one. */ + it.each([ + { kind: "Text", value: "ada@example.com" }, + { kind: "Number", value: 0 }, + { kind: "Boolean", value: false }, + { kind: "Date", value: "2026-02-28" }, + ])("outputs a $kind value as that kind", async ({ kind, value }) => { + expect(await read(kind, value)).toEqual({ + code: StatusCode.Ok, + contents: [ + { outputs: [{ outputName: "value", payload: { kind, value } }] }, + ], + }); + }); + + it.each([ + { name: "doesn't have the property", value: undefined }, + { name: "has null for it", value: null }, + { name: "has an empty value for it", value: "" }, + ])( + "gives no output, so later steps are skipped, when the entity $name", + async ({ value }) => { + expect(await read("Text", value)).toMatchObject({ + code: StatusCode.Ok, + contents: [{ outputs: [] }], + }); + }, + ); + + it("fails for an entity that is missing or the user can't read", async () => { + mockEntities([]); + + expect(await run({ entityId: entityIdOf(1), payloadKind: "Text" })).toEqual( + { + code: StatusCode.NotFound, + message: `Entity ${entityIdOf(1)} wasn't found or you don't have access to it.`, + contents: [], + }, + ); + }); + + it.each([[["ada@example.com", "ada@example.org"]], [["ada@example.com"]]])( + "fails for a property with a list of values: %j", + async (values) => { + const { code, message } = await read("Text", values); + expect(code).toBe(StatusCode.InvalidArgument); + expect(message).toContain("has a list of values"); + }, + ); + + it.each([ + { kind: "Text", value: 42 }, + { kind: "Number", value: "42" }, + { kind: "Boolean", value: "true" }, + { kind: "Date", value: "2026-02-30" }, + { kind: "Date", value: "2026-02-28T12:00:00Z" }, + { kind: "Text", value: { nested: "object" } }, + ])( + "fails for a value that isn't the declared kind: $kind, $value", + async ({ kind, value }) => { + const { code, message, contents } = await read(kind, value); + expect(code).toBe(StatusCode.InvalidArgument); + expect(message).toContain(`is read as ${kind}`); + expect(contents).toEqual([]); + }, + ); + + it("fails for a kind it can't read, without reading the entity", async () => { + expect( + await run({ entityId: entityIdOf(1), payloadKind: "Duration" }), + ).toMatchObject({ + code: StatusCode.InvalidArgument, + message: "A property can only be read as Text, Number, Boolean, Date.", + }); + expect(queryEntities).not.toHaveBeenCalled(); + }); + + it("fails for an entity ID that is empty, rather than treating it as having no properties", async () => { + expect( + await run({ entityId: "" as EntityId, payloadKind: "Text" }), + ).toMatchObject({ code: StatusCode.InvalidArgument }); + }); + + it("reads the entity an item of a stored list of entity IDs names", async () => { + const stored = await storePayload({ + storageProvider, + workflowId, + runId: "run", + stepId: "query", + outputName: "entities", + kind: "EntityId", + value: [entityIdOf(1), entityIdOf(2)], + }); + + mockEntities([{ entityId: entityIdOf(2), value: "grace@example.com" }]); + + expect( + await run({ + entityId: { + __stored: true, + kind: "EntityId", + array: false, + of: stored, + index: 1, + }, + payloadKind: "Text", + }), + ).toEqual({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "value", + payload: { kind: "Text", value: "grace@example.com" }, + }, + ], + }, + ], + }); + }); +}); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.ts new file mode 100644 index 00000000000..a9ff9155d28 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/read-entity-action.ts @@ -0,0 +1,153 @@ +import { isEntityId } from "@blockprotocol/type-system"; +import { + getStorageProvider, + resolvePayloadValue, +} from "@local/hash-backend-utils/flows/payload-storage"; +import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import { isIsoDate } from "@local/hash-isomorphic-utils/flows/datetime"; +import { validateEntityProperty } from "@local/hash-isomorphic-utils/flows/read-entity"; +import { StatusCode } from "@local/status"; + +import { getFlowContext } from "../shared/get-flow-context.js"; +import { + isEmptyValue, + resolveSubject, +} from "./filter-action/evaluate-condition.js"; +import { getConditionContext } from "./filter-action/get-condition-context.js"; + +import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; +import type { FlowActionStepOutput } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import type { ReadEntityKind } from "@local/hash-isomorphic-utils/flows/read-entity"; + +/** How to tell a value of each kind, and how to describe one. */ +const readableKinds: Record< + ReadEntityKind, + { isOfKind: (value: unknown) => boolean; description: string } +> = { + Text: { + isOfKind: (value) => typeof value === "string", + description: "text", + }, + Number: { + isOfKind: (value) => typeof value === "number" && Number.isFinite(value), + description: "a finite number", + }, + Boolean: { + isOfKind: (value) => typeof value === "boolean", + description: "true or false", + }, + Date: { + isOfKind: (value) => typeof value === "string" && isIsoDate(value), + description: "an ISO calendar date (YYYY-MM-DD)", + }, +}; + +const invalidArgument = (message: string) => ({ + code: StatusCode.InvalidArgument, + message, + contents: [], +}); + +/** + * Reads one property of an entity, as the user running the flow, and outputs its value as the kind the step declares. + * It loads the edition the entity ID names, as Filter does: the draft for an entity ID with a draft ID. + * + * Without a value, the step has no output, so steps that read it with `whenMissing: "skip"` are skipped. An entity + * that is missing or that the user can't read, a property with a list of values, and a value that isn't of the + * declared kind are errors. + */ +export const readEntityAction: FlowActionActivity<"readEntity"> = async ({ + inputs, +}) => { + const { entityId, property } = getSimplifiedFlowActionInputs({ + inputs, + actionType: "readEntity", + }); + + const propertyError = validateEntityProperty(property); + + if (propertyError) { + return invalidArgument(propertyError); + } + + const { userAuthentication, workflowId } = await getFlowContext(); + + /* An item of a stored list of entity IDs, e.g. in a for-each step, is a reference to it. */ + const resolvedEntityId = await resolvePayloadValue( + { storageProvider: getStorageProvider(), workflowId }, + "EntityId", + entityId, + ); + + /* Checked here, as Filter treats an empty entity ID as having no properties, rather than as an error. */ + if (!isEntityId(resolvedEntityId)) { + return invalidArgument( + `${String(resolvedEntityId)} is not a valid EntityId.`, + ); + } + + const resolved = resolveSubject( + property, + { kind: "EntityId", value: resolvedEntityId }, + await getConditionContext({ + authentication: userAuthentication, + subjects: [property], + kind: "EntityId", + values: [resolvedEntityId], + }), + ); + + if (resolved.status === "error") { + return { + code: + resolved.code === "entityRequired" + ? StatusCode.NotFound + : StatusCode.InvalidArgument, + message: resolved.message, + contents: [], + }; + } + + const { value } = resolved; + const { propertyTypeBaseUrl, payloadKind } = property; + + if (Array.isArray(value)) { + return invalidArgument( + `Property ${propertyTypeBaseUrl} of entity ${resolvedEntityId} has a list of values, but only a single value can be read.`, + ); + } + + if (isEmptyValue(value)) { + return { + code: StatusCode.Ok, + message: `Entity ${resolvedEntityId} has no value for property ${propertyTypeBaseUrl}`, + contents: [{ outputs: [] }], + }; + } + + const { isOfKind, description } = readableKinds[payloadKind]; + + if (!isOfKind(value)) { + return invalidArgument( + `Property ${propertyTypeBaseUrl} of entity ${resolvedEntityId} is read as ${payloadKind}, but its value ${JSON.stringify(value)} isn't ${description}.`, + ); + } + + return { + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "value", + /* `isOfKind` checked that the value has the declared kind. */ + payload: { + kind: payloadKind, + value, + } as FlowActionStepOutput<"readEntity">["payload"], + }, + ], + }, + ], + }; +}; diff --git a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts index 8ecdc409400..36defbde602 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts +++ b/apps/hash-frontend/src/pages/@/[shortname]/shared/flow-visualizer/run-flow-modal/types.ts @@ -16,6 +16,7 @@ const unsupportedPayloadKinds = [ "Duration", /** @todo support EntityId – requires updating the EntitySelector */ "EntityId", + "EntityProperty", "EntityQuery", "FailedEntityProposal", "FilterCondition", diff --git a/libs/@local/hash-backend-utils/src/flows/process-flow-workflow.ts b/libs/@local/hash-backend-utils/src/flows/process-flow-workflow.ts index c9b5a7e89df..329772bfea8 100644 --- a/libs/@local/hash-backend-utils/src/flows/process-flow-workflow.ts +++ b/libs/@local/hash-backend-utils/src/flows/process-flow-workflow.ts @@ -11,7 +11,10 @@ import { assertNoStoredPayloadRefsInFlowInputs, getArrayPayloadItems, } from "@local/hash-isomorphic-utils/flows/stored-payload-refs"; -import { asPayload } from "@local/hash-isomorphic-utils/flows/types"; +import { + asPayload, + getDeclaredKind, +} from "@local/hash-isomorphic-utils/flows/types"; import { getAllStepDefinitionsInFlowDefinition } from "@local/hash-isomorphic-utils/flows/util"; import { stringifyError } from "@local/hash-isomorphic-utils/stringify-error"; import { StatusCode } from "@local/status"; @@ -537,13 +540,14 @@ const processFlow = async < ); /* - * An output that takes its kind from an input (`kindFrom`) is only typed as any kind that input accepts, so check - * the action returned the kind it was given. + * An output that takes its kind from an input (`kindFrom`) is only typed as any kind that input accepts, and one + * whose kind an input declares (`kindDeclaredBy`) as any of its kinds, so check the action returned the kind it + * was given or declared. */ for (const outputDefinition of actionDefinitions[ currentStep.actionDefinitionId ].outputs) { - if (!("kindFrom" in outputDefinition)) { + if ("payloadKind" in outputDefinition) { continue; } @@ -551,14 +555,24 @@ const processFlow = async < ({ outputName }) => outputName === outputDefinition.name, )?.payload.kind; - const inputKind = currentStep.inputs?.find( - ({ inputName }) => inputName === outputDefinition.kindFrom, - )?.payload.kind; + const inputName = + "kindFrom" in outputDefinition + ? outputDefinition.kindFrom + : outputDefinition.kindDeclaredBy; + + const inputPayload = currentStep.inputs?.find( + (input) => input.inputName === inputName, + )?.payload; + + const expectedKind = + "kindFrom" in outputDefinition + ? inputPayload?.kind + : getDeclaredKind(outputDefinition, inputPayload?.value); - if (outputKind !== undefined && outputKind !== inputKind) { + if (outputKind !== undefined && outputKind !== expectedKind) { processStepErrors[currentStepId] = { code: StatusCode.Internal, - message: `Action ${currentStep.actionDefinitionId} returned output "${outputDefinition.name}" of kind ${outputKind}, but it must have the kind of input "${outputDefinition.kindFrom}" (${inputKind ?? "not given"})`, + message: `Action ${currentStep.actionDefinitionId} returned output "${outputDefinition.name}" of kind ${outputKind}, but it must have the kind input "${inputName}" ${"kindFrom" in outputDefinition ? "has" : "declares"} (${expectedKind ?? "not given"})`, }; return; diff --git a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts index 19b5e30fb56..a00fad473e0 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/action-definitions.ts @@ -1,6 +1,7 @@ import { checkAggregationInput } from "./aggregation.js"; import { checkConditionInput, filterableKinds } from "./filter-condition.js"; import { maxEntitiesPerStep } from "./limits.js"; +import { checkReadEntityInputs, readEntityKinds } from "./read-entity.js"; import { checkSortKeyInput } from "./sort-key.js"; import { checkTakeCountInput } from "./take-count.js"; import { allPayloadKinds } from "./types.js"; @@ -42,6 +43,7 @@ export type AiFlowActionDefinitionId = | "persistEntities" | "persistEntity" | "queryEntities" + | "readEntity" | "researchEntities" | "takeFirst" | "webSearch" @@ -1031,6 +1033,42 @@ const aiFlowActionDefinitionsAsConst = { }, ], }, + readEntity: { + actionDefinitionId: "readEntity", + name: "Read Entity", + description: + "Read one property of an entity as a plain value, e.g. a person's email address to send an email to.", + kind: "action", + inputs: [ + { + oneOfPayloadKinds: ["EntityId"], + name: "entityId", + description: + "The entity to read: the draft for an entity ID with a draft ID, otherwise the live entity.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["EntityProperty"], + name: "property", + description: `The property to read, and its kind: ${readEntityKinds.join(", ")}.`, + required: true, + array: false, + }, + ], + outputs: [ + { + kindDeclaredBy: "property", + oneOfPayloadKinds: readEntityKinds, + name: "value", + description: + "The property's value, of the kind its input declares. Missing if the entity doesn't have the property.", + array: false, + required: false, + }, + ], + check: checkReadEntityInputs, + }, takeFirst: { actionDefinitionId: "takeFirst", name: "Take First", @@ -1520,7 +1558,8 @@ export const getSimplifiedFlowActionInputs = < * * An output with a derived kind is typed as a payload of the kind its kind source was given, out of `Kinds` (see * `InputKindsOf`). Where `Kinds` holds every kind each kind source accepts, as it does outside the action, the engine - * checks the kind when the action returns. + * checks the kind when the action returns. An output whose kind an input declares (see `KindDeclaredBy`) is typed as a + * payload of any of its kinds, and the engine checks it has the kind declared. */ type ActionStepOutput = Output extends { name: infer OutputName extends string; @@ -1536,7 +1575,15 @@ type ActionStepOutput = Output extends { outputName: OutputName; payload: PayloadWithKind, IsArray>; } - : never + : Output extends { + kindDeclaredBy: string; + oneOfPayloadKinds: readonly (infer Kind extends PayloadKind)[]; + } + ? { + outputName: OutputName; + payload: PayloadOfEachKind; + } + : never : never; /** diff --git a/libs/@local/hash-isomorphic-utils/src/flows/define-flow.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/define-flow.test.ts index 2ec398757a3..1967c08bf9c 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/define-flow.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/define-flow.test.ts @@ -969,3 +969,144 @@ describe("Filter List and If steps", () => { expect(typeChecks).toBeDefined(); }); }); + +describe("Read Entity steps", () => { + const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; + const personType = + "https://example.com/types/entity-type/person/v/1" as VersionedUrl; + + it("type the value read by the kind the property declares", () => { + const { flowDefinition } = defineFlow( + { + flowDefinitionId: "email-people", + name: "Email people", + description: "Read each person's email address and use it", + inputs: {}, + }, + ({ step, forEach }) => { + const people = step("people", actions.queryEntities, { + description: "Find people", + inputs: { + query: constant("EntityQuery", { + filter: { + equal: [ + { path: ["type", "versionedUrl"] }, + { parameter: personType }, + ], + }, + }), + }, + }); + + const explanations = forEach("eachPerson", people.outputs.entities, { + description: "For each person", + collectAs: "explanations", + steps: (person, scope) => { + const email = scope.step("email", actions.readEntity, { + description: "Read their email address", + inputs: { + entityId: person, + property: constant("EntityProperty", { + kind: "entityProperty", + propertyTypeBaseUrl: emailUrl, + payloadKind: "Text", + }), + }, + }); + + expectTypeOf(email.outputs.value).toEqualTypeOf< + Ref<"Text", false, false> + >(); + + return scope.step("answer", actions.answerQuestion, { + description: "Answer a question about it", + inputs: { question: email.outputs.value.orSkip() }, + }).outputs.explanation; + }, + }); + + return { outputs: { explanations } }; + }, + ); + + expect(flowDefinition.steps[1]).toMatchObject({ + kind: "for-each", + steps: [ + { stepId: "email", inputs: { entityId: { kind: "item" } } }, + { + stepId: "answer", + inputs: { + question: { + kind: "step-output", + stepId: "email", + outputName: "value", + whenMissing: "skip", + }, + }, + }, + ], + }); + }); + + it("reject passing the value read to an input of another kind, without skipping when it's missing, or of a kind no constant declares", () => { + const typeChecks = ( + step: AddStep, + person: Ref<"EntityId", false, true>, + propertyInput: Ref<"EntityProperty", false, true>, + ) => { + const age = step("age", actions.readEntity, { + description: "", + inputs: { + entityId: person, + property: constant("EntityProperty", { + kind: "entityProperty", + propertyTypeBaseUrl: emailUrl, + payloadKind: "Number", + }), + }, + }); + + step("answerWithNumber", actions.answerQuestion, { + description: "", + // @ts-expect-error -- the property is read as a Number, and `question` takes Text + inputs: { question: age.outputs.value.orSkip() }, + }); + + const email = step("email", actions.readEntity, { + description: "", + inputs: { + entityId: person, + property: constant("EntityProperty", { + kind: "entityProperty", + propertyTypeBaseUrl: emailUrl, + payloadKind: "Text", + }), + }, + }); + + step("answerWithoutSkipping", actions.answerQuestion, { + description: "", + // @ts-expect-error -- the value is missing when the entity doesn't have the property + inputs: { question: email.outputs.value }, + }); + + /* The validator rejects a property that isn't a constant; the types can only say the kind is any it could be. */ + const unknown = step("unknown", actions.readEntity, { + description: "", + inputs: { entityId: person, property: propertyInput }, + }); + + expectTypeOf(unknown.outputs.value).toEqualTypeOf< + Ref<"Text" | "Number" | "Boolean" | "Date", false, false> + >(); + + step("answerWithUnknown", actions.answerQuestion, { + description: "", + // @ts-expect-error -- the kind isn't known + inputs: { question: unknown.outputs.value.orSkip() }, + }); + }; + + expect(typeChecks).toBeDefined(); + }); +}); diff --git a/libs/@local/hash-isomorphic-utils/src/flows/define-flow.ts b/libs/@local/hash-isomorphic-utils/src/flows/define-flow.ts index e29b46d9ac9..91009a079ce 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/define-flow.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/define-flow.ts @@ -37,6 +37,11 @@ type ActionDefinitionShape = { | { readonly payloadKind: PayloadKind } /** A derived kind (see `KindFrom`). */ | { readonly kindFrom: string } + /** A declared kind (see `KindDeclaredBy`). */ + | { + readonly kindDeclaredBy: string; + readonly oneOfPayloadKinds: readonly PayloadKind[]; + } ))[]; }; @@ -59,17 +64,22 @@ declare const refShape: unique symbol; * for the rule they mirror). * * `IsRequired` means the value is always present once its producer has run. + * + * `DeclaredKind` is the kind a constant declares for an output (see `KindDeclaredBy`): any kind, unless it's a constant + * whose value has a `payloadKind`. */ export type Ref< Kind extends PayloadKind, IsArray extends boolean, IsRequired extends boolean, + DeclaredKind extends PayloadKind = PayloadKind, > = { /** Type-level only: carries the value's shape for assignability checks. */ readonly [refShape]?: { payloadKind: Kind; array: IsArray; required: IsRequired; + declaredKind: DeclaredKind; }; readonly source: StepInputSource; /* @@ -77,16 +87,16 @@ export type Ref< * parameters. `wrap`'s `this` parameter still stops it being called on an array ref. */ /** Leave the value available to other consumers. */ - read(): Ref; + read(): Ref; /** Take the value, so no other consumer can. */ - consume(): Ref; + consume(): Ref; /** Wrap a singular value into a one-item array, to feed an array input. */ wrap(this: Ref): Ref; /** * Allow a value that may be missing to feed a required input, by skipping the consuming step (and * everything that depends on it) when the value is missing. */ - orSkip(): Ref; + orSkip(): Ref; }; type AnyRef = Ref; @@ -164,6 +174,12 @@ export type StepInputs = { type RefKind = MaybeRef extends Ref ? Kind : never; +/** The kind a ref declares (see `Ref`), or `never` if there's no ref. */ +type RefDeclaredKind = + MaybeRef extends Ref + ? DeclaredKind + : never; + /** * Narrows each input with a derived kind to accept only the kind of the ref given to its kind source. */ @@ -197,7 +213,8 @@ type NoUnknownInputs = { /** * The refs `step()` returns for a step's outputs. * - * An output with a derived kind gets the kind of the ref given to its kind source. + * An output with a derived kind gets the kind of the ref given to its kind source, and one with a declared kind the + * kind the constant given to its declaring input declares, out of the output's kinds. */ export type StepOutputs< Action extends ActionDefinitionShape, @@ -215,7 +232,19 @@ export type StepOutputs< Output["array"], Output["required"] > - : never; + : Output extends { + readonly kindDeclaredBy: infer DeclaringInputName; + readonly oneOfPayloadKinds: readonly (infer Kind extends + PayloadKind)[]; + } + ? Ref< + DeclaringInputName extends keyof InputRefs + ? RefDeclaredKind & Kind + : never, + Output["array"], + Output["required"] + > + : never; }; /** @@ -378,6 +407,11 @@ type Constant = { payloadKind: Kind, value: readonly PayloadKindValues[Kind][], ): Ref; + /** A value that declares a kind for an output (see `KindDeclaredBy`). */ + ( + payloadKind: Kind, + value: PayloadKindValues[Kind] & { payloadKind: DeclaredKind }, + ): Ref; ( payloadKind: Kind, value: PayloadKindValues[Kind], @@ -385,7 +419,8 @@ type Constant = { }; /** - * A fixed value for an input, the same in every run. + * A fixed value for an input, the same in every run. A singular value that declares a kind for an output (see + * `KindDeclaredBy`) keeps that kind in its type. */ export const constant = ((payloadKind: PayloadKind, value: unknown) => createRef({ diff --git a/libs/@local/hash-isomorphic-utils/src/flows/read-entity.ts b/libs/@local/hash-isomorphic-utils/src/flows/read-entity.ts new file mode 100644 index 00000000000..df8108e4acd --- /dev/null +++ b/libs/@local/hash-isomorphic-utils/src/flows/read-entity.ts @@ -0,0 +1,80 @@ +import { validateSubject } from "./filter-condition.js"; + +import type { ConditionSubject } from "./filter-condition.js"; +import type { ActionStepCheck, PayloadKind } from "./types.js"; + +/** The kinds Read entity can read a property as. */ +export const readEntityKinds = [ + "Text", + "Number", + "Boolean", + "Date", +] as const satisfies readonly PayloadKind[]; + +export type ReadEntityKind = (typeof readEntityKinds)[number]; + +/** + * A property of an entity to read, and the kind its value has: a Filter condition's entity property subject, of one of + * the {@link readEntityKinds}. + * + * The kind is declared rather than looked up, because flows are validated without access to the ontology. Read entity + * checks the value has it when it runs. + */ +export type EntityProperty = Extract< + ConditionSubject, + { kind: "entityProperty" } +> & { payloadKind: ReadEntityKind }; + +/** Checks that the property a Read entity step reads is an entity property subject, returning why it isn't. */ +const validatePropertySubject = ( + property: EntityProperty, +): string | undefined => { + /* + * Checked first, so that another subject isn't reported for what it lacks as that subject. Persisted definitions + * may be malformed. + */ + if ((property as ConditionSubject | null)?.kind !== "entityProperty") { + return "The property to read must be an entity property subject."; + } + return validateSubject(property, "EntityId"); +}; + +/** Checks the property a Read entity step reads, and the kind it's read as, returning why it's invalid. */ +export const validateEntityProperty = ( + property: EntityProperty, +): string | undefined => { + const subjectError = validatePropertySubject(property); + if (subjectError) { + return subjectError; + } + if ( + !(readEntityKinds as readonly PayloadKind[]).includes(property.payloadKind) + ) { + return `A property can only be read as ${readEntityKinds.join(", ")}.`; + } + return undefined; +}; + +/** + * Checks a Read entity step's constant `property` input when the flow is defined. That it is a constant declaring one + * of the {@link readEntityKinds} is checked with every output whose kind an input declares (see `KindDeclaredBy`). + */ +export const checkReadEntityInputs: ActionStepCheck = ({ step, report }) => { + const { property } = step.inputs; + + /* A constant of another kind is reported as an incompatible connection. */ + if ( + property?.kind !== "constant" || + property.payload.kind !== "EntityProperty" + ) { + return; + } + + const error = validatePropertySubject( + property.payload.value as EntityProperty, + ); + + if (error) { + report("property", error); + } +}; diff --git a/libs/@local/hash-isomorphic-utils/src/flows/step-shapes.ts b/libs/@local/hash-isomorphic-utils/src/flows/step-shapes.ts index dbba6482358..b3abf7b0e90 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/step-shapes.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/step-shapes.ts @@ -1,4 +1,5 @@ import { actionDefinitions as defaultActionDefinitions } from "./action-definitions.js"; +import { getDeclaredKind } from "./types.js"; import type { ConnectionSource } from "./can-connect.js"; import type { @@ -21,7 +22,7 @@ export type ActionDefinitions = Record< * * Most step outputs have the shape their action declares. An output with a derived kind (see `KindFrom`) gets the * kind connected to its kind source, following `forEach` items, collected outputs and other derived kinds to find - * it. + * it. An output whose kind an input declares (see `KindDeclaredBy`) gets the kind the constant given to it declares. * * Returns `null` for anything it can't resolve. The validator uses it to check connections, and the engine to find a * kind where it has no value to read the kind from. @@ -180,6 +181,19 @@ export const createStepShapeResolver = ( }; } + if ("kindDeclaredBy" in output) { + const source = step.inputs[output.kindDeclaredBy]; + + const payloadKind = + source?.kind === "constant" + ? getDeclaredKind(output, source.payload.value) + : null; + + return payloadKind + ? { payloadKind, array: output.array, required: output.required } + : null; + } + const key = `${stepId}\u0000${outputName}`; if (resolving.has(key)) { diff --git a/libs/@local/hash-isomorphic-utils/src/flows/types.ts b/libs/@local/hash-isomorphic-utils/src/flows/types.ts index 1543463947d..0465f34fbc3 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/types.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/types.ts @@ -8,6 +8,7 @@ import type { } from "./action-definitions.js"; import type { Aggregation } from "./aggregation.js"; import type { FilterCondition } from "./filter-condition.js"; +import type { EntityProperty } from "./read-entity.js"; import type { SortKey } from "./sort-key.js"; import type { ActorEntityUuid, @@ -125,6 +126,7 @@ export type PayloadKindValues = { DateTime: string; Duration: Duration; EntityId: EntityId; + EntityProperty: EntityProperty; EntityQuery: EntityQuery; FailedEntityProposal: FailedEntityProposal; FilterCondition: FilterCondition; @@ -178,6 +180,7 @@ export const payloadKindTraits = { DateTime: { primitive: "datetime" }, Duration: { primitive: null }, EntityId: { primitive: "text", entity: true }, + EntityProperty: { primitive: null }, EntityQuery: { primitive: null }, FailedEntityProposal: { primitive: null }, FilterCondition: { primitive: null }, @@ -528,8 +531,38 @@ export type InputDefinition = { } & ({ oneOfPayloadKinds: readonly PayloadKind[] } | KindFrom); /** - * An action's output: a fixed kind, or the kind connected to one of its inputs (see `KindFrom`). A flow's outputs - * are `FlowOutputDefinition`s. + * An action output whose kind a constant input declares: the `payloadKind` of the value given to the input + * `kindDeclaredBy`, one of `oneOfPayloadKinds`. E.g. Read entity's `value` output has the kind its `property` input + * says the property has. + * + * The input must be a constant, so that the kind is known when the flow is defined. The action's own check (see + * `ActionStepCheck`) checks that the constant declares one of `oneOfPayloadKinds`, and the engine checks that the + * action returns that kind. + */ +export type KindDeclaredBy = { + kindDeclaredBy: string; + oneOfPayloadKinds: readonly PayloadKind[]; +}; + +/** + * The kind a value given to an output's declaring input declares (see `KindDeclaredBy`), or `null` if it doesn't + * declare one of the output's kinds. + */ +export const getDeclaredKind = ( + output: KindDeclaredBy, + value: unknown, +): PayloadKind | null => { + const declaredKind = + typeof value === "object" && value !== null && "payloadKind" in value + ? value.payloadKind + : undefined; + + return output.oneOfPayloadKinds.find((kind) => kind === declaredKind) ?? null; +}; + +/** + * An action's output: a fixed kind, the kind connected to one of its inputs (see `KindFrom`), or the kind a constant + * input declares (see `KindDeclaredBy`). A flow's outputs are `FlowOutputDefinition`s. */ export type OutputDefinition< IsArray extends boolean = boolean, @@ -539,7 +572,7 @@ export type OutputDefinition< description?: string; array: IsArray; required: boolean; -} & ({ payloadKind: Kind } | KindFrom); +} & ({ payloadKind: Kind } | KindFrom | KindDeclaredBy); /** * A check an action needs beyond the shapes of its inputs, e.g. that a constant fits the kind of another input. diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts index cb4e98d0a54..97da585e05e 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.test.ts @@ -732,6 +732,158 @@ describe("validateFlowDefinition", () => { }); }); + describe("readEntity steps", () => { + const emailUrl = "https://example.com/types/property-type/email/"; + + const property = (overrides: Record = {}) => ({ + kind: "constant", + payload: { + kind: "EntityProperty", + value: { + kind: "entityProperty", + propertyTypeBaseUrl: emailUrl, + payloadKind: "Text", + ...overrides, + }, + }, + }); + + const readDefinition = (params: { + property: unknown; + /** How the value read is connected to the question of a later step, if it is. */ + question?: Record; + }) => ({ + ...baseDefinition, + inputs: [ + { + name: "person", + payloadKind: "EntityId", + array: false, + required: true, + }, + { + name: "property", + payloadKind: "EntityProperty", + array: false, + required: true, + }, + ], + steps: [ + { + kind: "action", + stepId: "read", + actionDefinitionId: "readEntity", + description: "", + inputs: { + entityId: { kind: "flow-input", inputName: "person" }, + property: params.property, + }, + }, + ...(params.question + ? [ + answerStep("answer", { + question: { + kind: "step-output", + stepId: "read", + outputName: "value", + ...params.question, + }, + }), + ] + : []), + ], + }); + + const errorsOf = (params: Parameters[0]) => + validateFlowDefinition(readDefinition(params)).diagnostics.filter( + ({ severity }) => severity === "error", + ); + + it("gives the value the kind the property declares, which a later step skipped when it's missing can take", () => { + expect( + errorsOf({ property: property(), question: { whenMissing: "skip" } }), + ).toEqual([]); + }); + + it.each([ + { + name: "a value that may be missing, unless the step is skipped", + params: { property: property(), question: {} }, + message: "may be missing", + }, + { + name: "a value of a kind the later step doesn't accept", + params: { + property: property({ payloadKind: "Number" }), + question: { whenMissing: "skip" }, + }, + message: "Number", + }, + ])("rejects passing on $name", ({ params, message }) => { + expect(errorsOf(params)).toEqual([ + expect.objectContaining({ + code: "incompatibleConnection", + message: expect.stringContaining(message), + path: ["steps", 1, "inputs", "question"], + }), + ]); + }); + + it.each([ + { + name: "a property type versioned URL rather than a base URL", + overrides: { + propertyTypeBaseUrl: + "https://example.com/types/property-type/email/v/1", + }, + code: "invalidInput", + message: + "An entity property subject requires a property type base URL.", + }, + ...[{ kind: "entityType" }, { kind: "field", path: ["email"] }].map( + (subject) => ({ + name: `a subject other than an entity property: ${subject.kind}`, + overrides: subject, + code: "invalidInput", + message: "The property to read must be an entity property subject.", + }), + ), + { + name: "a missing kind", + overrides: { payloadKind: undefined }, + code: "invalidInput", + message: "An entity property subject requires a known payload kind.", + }, + ...["EntityId", "DateTime", "Duration"].map((payloadKind) => ({ + name: `the kind ${payloadKind}, which the value can't have`, + overrides: { payloadKind }, + code: "unresolvedKind", + message: + 'The output "value" of action "readEntity" has the kind input "property" declares, which must be one of Text, Number, Boolean, Date', + })), + ])("rejects $name", ({ overrides, code, message }) => { + expect(errorsOf({ property: property(overrides) })).toEqual([ + { + severity: "error", + code, + message, + path: ["steps", 0, "inputs", "property"], + }, + ]); + }); + + it("rejects a property that isn't a constant, whose kind isn't known until the flow runs", () => { + expect( + errorsOf({ property: { kind: "flow-input", inputName: "property" } }), + ).toEqual([ + expect.objectContaining({ + code: "unresolvedKind", + path: ["steps", 0, "inputs", "property"], + }), + ]); + }); + }); + describe("aggregations of aggregate steps", () => { const aggregateDefinition = (aggregation: Record) => ({ ...baseDefinition, diff --git a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.ts b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.ts index 1eae81699ed..1bc044d5948 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/validate-flow-definition.ts @@ -9,7 +9,7 @@ import { type ActionDefinitions, createStepShapeResolver, } from "./step-shapes.js"; -import { getPayloadKindJsonType } from "./types.js"; +import { getDeclaredKind, getPayloadKindJsonType } from "./types.js"; import type { ActionDefinition, @@ -46,7 +46,10 @@ export type FlowDefinitionDiagnosticCode = | "invalidCollect" | "cycle" | "unusedFlowInput" - /** An input or output with a derived kind (see `KindFrom`), whose kind source isn't connected. */ + /** + * An input or output with a derived kind (see `KindFrom`), whose kind source isn't connected, or an output whose kind + * an input declares (see `KindDeclaredBy`), given something other than a constant. + */ | "unresolvedKind" /** An input with a derived kind, given a value of a different kind from its kind source's. */ | "kindMismatch" @@ -610,6 +613,26 @@ export const validateFlowDefinition = ( } } + /* + * Outputs whose kind an input declares, which must be a constant for the kind to be known. What the constant + * declares is checked after the action's own check, below. + */ + for (const output of actionDefinition.outputs) { + if (!("kindDeclaredBy" in output)) { + continue; + } + + const source = step.inputs[output.kindDeclaredBy]; + + if (source && source.kind !== "constant") { + report( + "unresolvedKind", + `The output "${output.name}" of action "${step.actionDefinitionId}" has the kind input "${output.kindDeclaredBy}" declares, so that input must be a constant`, + [...entry.path, "inputs", output.kindDeclaredBy], + ); + } + } + for (const actionInput of actionDefinition.inputs) { if (isMandatoryInput(actionInput) && !(actionInput.name in step.inputs)) { report( @@ -620,12 +643,47 @@ export const validateFlowDefinition = ( } } + const checkedInputs = new Set(); + actionDefinition.check?.({ step, getInputKind: (inputName) => shapes.getInputKind(step.stepId, inputName), - report: (inputName, message) => - report("invalidInput", message, [...entry.path, "inputs", inputName]), + report: (inputName, message) => { + checkedInputs.add(inputName); + report("invalidInput", message, [...entry.path, "inputs", inputName]); + }, }); + + /* + * A constant given to an output's declaring input must declare one of the output's kinds. Not reported for an input + * the action's check already found invalid, nor for a constant of a kind the input doesn't accept, which is an + * incompatible connection. + */ + for (const output of actionDefinition.outputs) { + if (!("kindDeclaredBy" in output)) { + continue; + } + + const source = step.inputs[output.kindDeclaredBy]; + const declaringInput = actionDefinition.inputs.find( + ({ name }) => name === output.kindDeclaredBy, + ); + + if ( + source?.kind === "constant" && + !checkedInputs.has(output.kindDeclaredBy) && + declaringInput && + "oneOfPayloadKinds" in declaringInput && + declaringInput.oneOfPayloadKinds.includes(source.payload.kind) && + !getDeclaredKind(output, source.payload.value) + ) { + report( + "unresolvedKind", + `The output "${output.name}" of action "${step.actionDefinitionId}" has the kind input "${output.kindDeclaredBy}" declares, which must be one of ${output.oneOfPayloadKinds.join(", ")}`, + [...entry.path, "inputs", output.kindDeclaredBy], + ); + } + } }; /*