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..db81c08d530 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities.ts @@ -1,6 +1,7 @@ import { aggregateAction } from "./flow-activities/aggregate-action.js"; import { analyzeEntityDataAction } from "./flow-activities/analyze-entity-data-action.js"; import { answerQuestionAction } from "./flow-activities/answer-question-action.js"; +import { createEntityAction } from "./flow-activities/create-entity-action.js"; import { filterListAction } from "./flow-activities/filter-list-action.js"; import { findAction } from "./flow-activities/find-action.js"; import { generateChartConfigAction } from "./flow-activities/generate-chart-config-action.js"; @@ -21,6 +22,7 @@ import { processAutomaticBrowsingSettingsAction } from "./flow-activities/proces import { queryEntitiesAction } from "./flow-activities/query-entities-action.js"; import { researchEntitiesAction } from "./flow-activities/research-entities-action.js"; import { takeFirstAction } from "./flow-activities/take-first-action.js"; +import { updateEntityAction } from "./flow-activities/update-entity-action.js"; import { webSearchAction } from "./flow-activities/web-search-action.js"; import { writeGoogleSheetAction } from "./flow-activities/write-google-sheet-action.js"; @@ -35,6 +37,7 @@ export const createFlowActionActivities: CreateFlowActivities< analyzeEntityDataAction, generateStructuralQueryAction, answerQuestionAction, + createEntityAction, filterListAction, findAction, generateChartConfigAction, @@ -53,6 +56,7 @@ export const createFlowActionActivities: CreateFlowActivities< queryEntitiesAction, researchEntitiesAction, takeFirstAction, + updateEntityAction, webSearchAction, writeGoogleSheetAction( params: Omit[0], "vaultClient">, diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts new file mode 100644 index 00000000000..23904d27893 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.test.ts @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { hasPermissionForEntityTypes } from "@local/hash-graph-sdk/entity-type"; +import { StatusCode } from "@local/status"; + +import { createEntityAction } from "./create-entity-action.js"; + +import type { + BaseUrl, + EntityId, + PropertyObjectWithMetadata, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; +import type { + HashEntity, + QueryEntitiesResponse, +} from "@local/hash-graph-sdk/entity"; +import type { StepInput } from "@local/hash-isomorphic-utils/flows/types"; + +const webId = "00000000-0000-4000-8000-000000000001" as WebId; + +vi.mock("../shared/graph-api-client.js", () => ({ graphApiClient: {} })); +vi.mock("../shared/get-flow-context.js", () => ({ + getFlowContext: () => + Promise.resolve({ + flowEntityId: "flow", + runId: "run", + stepId: "create", + userAuthentication: { actorId: "user" }, + webId, + }), +})); +vi.mock("./shared/save-entity.js", () => ({ + getSavingActorId: () => Promise.resolve("bot"), + retrySave: (save: () => unknown) => save(), +})); +const { create } = vi.hoisted(() => ({ + create: vi.fn(), +})); +vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ + ...(await importOriginal()), + HashEntity: { create }, + queryEntities: vi.fn(), +})); +vi.mock("@local/hash-graph-sdk/entity-type", async (importOriginal) => ({ + ...(await importOriginal()), + hasPermissionForEntityTypes: vi.fn(), +})); + +const personType = + "https://example.com/types/entity-type/person/v/1" as VersionedUrl; +const employeeType = + "https://example.com/types/entity-type/employee/v/1" as VersionedUrl; +const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; +const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; +const text = { + dataTypeId: "https://example.com/types/data-type/text/v/1" as VersionedUrl, +}; + +const ada = { + value: { [nameUrl]: { value: "Ada", metadata: text } }, +} satisfies PropertyObjectWithMetadata; + +const run = (params: { + entityTypeIds?: string[]; + properties: PropertyObjectWithMetadata; + draft?: boolean; +}) => + createEntityAction({ + inputs: [ + { + inputName: "entityTypeIds", + payload: { + kind: "VersionedUrl", + value: (params.entityTypeIds ?? [personType]) as VersionedUrl[], + }, + }, + { + inputName: "properties", + payload: { + kind: "PropertyObjectWithMetadata", + value: params.properties, + }, + }, + ...(params.draft === undefined + ? [] + : [ + { + inputName: "draft", + payload: { kind: "Boolean" as const, value: params.draft }, + }, + ]), + ] satisfies StepInput[], + }); + +/** Answers the graph's creation with an entity with the given ID. */ +const mockCreated = (entityId: EntityId) => + create.mockResolvedValueOnce({ entityId } as unknown as HashEntity); + +describe("createEntityAction", () => { + beforeEach(() => { + create.mockReset(); + vi.mocked(queryEntities) + .mockReset() + .mockResolvedValue({ + entities: [], + cursor: null, + } as unknown as QueryEntitiesResponse); + vi.mocked(hasPermissionForEntityTypes) + .mockReset() + .mockImplementation((_graphApi, _authentication, { entityTypeIds }) => + Promise.resolve(entityTypeIds), + ); + }); + + it("creates an entity of the type with the properties as given, in the flow's web, and outputs its ID", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + mockCreated(entityId); + + const properties = { + value: { + [nameUrl]: { value: "Ada", metadata: text }, + [emailUrl]: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + } satisfies PropertyObjectWithMetadata; + + expect(await run({ properties })).toEqual({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entityId }, + }, + ], + }, + ], + }); + + expect(create).toHaveBeenCalledWith( + {}, + { actorId: "bot" }, + expect.objectContaining({ + webId, + entityTypeIds: [personType], + properties, + draft: false, + provenance: { + actorType: "machine", + origin: { type: "flow", id: "flow", stepIds: ["create"] }, + }, + }), + ); + }); + + it("creates a draft", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002~00000000-0000-4000-8000-000000000003` as EntityId; + mockCreated(entityId); + + expect(await run({ properties: ada, draft: true })).toMatchObject({ + code: StatusCode.Ok, + contents: [{ outputs: [{ payload: { value: entityId } }] }], + }); + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ draft: true }), + ); + }); + + it("creates an entity without properties", async () => { + mockCreated(`${webId}~00000000-0000-4000-8000-000000000002` as EntityId); + + expect(await run({ properties: { value: {} } })).toMatchObject({ + code: StatusCode.Ok, + }); + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ properties: { value: {} } }), + ); + }); + + it("creates an entity with every type given", async () => { + mockCreated(`${webId}~00000000-0000-4000-8000-000000000002` as EntityId); + + await run({ entityTypeIds: [personType, employeeType], properties: ada }); + + expect(create).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ entityTypeIds: [personType, employeeType] }), + ); + }); + + it.each([ + { + name: "no entity type IDs", + params: { entityTypeIds: [], properties: ada }, + message: "Give at least one entity type ID.", + }, + { + name: "an entity type ID that isn't a versioned URL", + params: { + entityTypeIds: [ + personType, + "https://example.com/types/entity-type/person/", + ], + properties: ada, + }, + message: + '"https://example.com/types/entity-type/person/" is not an entity type ID.', + }, + { + name: "a value without a data type ID", + params: { + properties: { + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, + }, + }, + }, + message: `Property ${nameUrl} must give the data type ID of each of its values.`, + }, + ])( + "fails for $name, without creating anything", + async ({ params, message }) => { + expect(await run(params)).toEqual({ + code: StatusCode.InvalidArgument, + message, + contents: [], + }); + expect(queryEntities).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }, + ); + + it("fails for a type the user running the flow can't instantiate, e.g. an actor type, without creating anything", async () => { + vi.mocked(hasPermissionForEntityTypes).mockResolvedValueOnce([personType]); + + expect( + await run({ entityTypeIds: [personType, employeeType], properties: ada }), + ).toEqual({ + code: StatusCode.PermissionDenied, + message: `Entity type ${employeeType} doesn't exist or can't be instantiated by the user running the flow.`, + contents: [], + }); + + expect(hasPermissionForEntityTypes).toHaveBeenCalledWith( + {}, + { actorId: "user" }, + { entityTypeIds: [personType, employeeType], action: "instantiate" }, + ); + expect(queryEntities).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("fails if the graph doesn't save the entity, e.g. because its properties don't fit the type", async () => { + create.mockRejectedValueOnce(new Error("Entity validation failed")); + + const { code, message } = await run({ properties: ada }); + + expect(code).toBe(StatusCode.Internal); + expect(message).toContain("Could not create entity"); + expect(message).toContain("Entity validation failed"); + }); + + it("gives the entity the same UUID on every attempt of the step in a run", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + mockCreated(entityId); + mockCreated(entityId); + + await run({ properties: ada }); + await run({ properties: ada }); + + const [first, second] = create.mock.calls.map( + ([, , params]) => params.entityUuid, + ); + + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(second).toBe(first); + }); + + it("outputs the entity an earlier attempt of the step created, without creating a second one", async () => { + const entityId = + `${webId}~00000000-0000-4000-8000-000000000002` as EntityId; + vi.mocked(queryEntities).mockResolvedValueOnce({ + entities: [{ entityId }], + cursor: null, + } as unknown as QueryEntitiesResponse); + + expect(await run({ properties: ada })).toMatchObject({ + code: StatusCode.Ok, + contents: [{ outputs: [{ payload: { value: entityId } }] }], + }); + + const query = vi.mocked(queryEntities).mock.calls[0]; + expect(query?.[1]).toEqual({ actorId: "bot" }); + expect(query?.[2].includeDrafts).toBe(true); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts new file mode 100644 index 00000000000..3f06ab279aa --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/create-entity-action.ts @@ -0,0 +1,168 @@ +import { + type EntityUuid, + validateVersionedUrl, +} from "@blockprotocol/type-system"; +import { HashEntity, queryEntities } from "@local/hash-graph-sdk/entity"; +import { hasPermissionForEntityTypes } from "@local/hash-graph-sdk/entity-type"; +import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import { validatePropertiesWithMetadata } from "@local/hash-isomorphic-utils/flows/property-values"; +import { generateUuidFromName } from "@local/hash-isomorphic-utils/generate-uuid"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { StatusCode } from "@local/status"; + +import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; +import { getFlowContext } from "../shared/get-flow-context.js"; +import { graphApiClient } from "../shared/graph-api-client.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; + +import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; + +const createdEntityUuidNamespace = "3c0f8a52-6d1e-4b7a-9f24-81e5c7d2a6b9"; + +const invalidArgument = (message: string) => ({ + code: StatusCode.InvalidArgument, + message, + contents: [], +}); + +/** + * Creates an entity of the given types in the flow's web, with the given properties and their metadata, as the web's + * machine on behalf of the user running the flow. The graph validates the entity; a draft may leave required properties + * out. + */ +export const createEntityAction: FlowActionActivity<"createEntity"> = async ({ + inputs, +}) => { + const { + entityTypeIds, + properties, + draft = false, + } = getSimplifiedFlowActionInputs({ inputs, actionType: "createEntity" }); + + const [firstEntityTypeId, ...otherEntityTypeIds] = entityTypeIds; + + if (!firstEntityTypeId) { + return invalidArgument("Give at least one entity type ID."); + } + + for (const entityTypeId of entityTypeIds) { + if (validateVersionedUrl(entityTypeId).type !== "Ok") { + return invalidArgument( + `${JSON.stringify(entityTypeId)} is not an entity type ID.`, + ); + } + } + + const propertiesError = validatePropertiesWithMetadata(properties); + + if (propertiesError) { + return invalidArgument(propertiesError); + } + + const { flowEntityId, runId, stepId, userAuthentication, webId } = + await getFlowContext(); + + /* + * The web's machine saves the entity, and may instantiate types the user can't, so the user's own permission to + * instantiate each type, and the types it inherits from, is checked here. + */ + const instantiableEntityTypeIds = new Set( + await hasPermissionForEntityTypes(graphApiClient, userAuthentication, { + entityTypeIds, + action: "instantiate", + }), + ); + + const forbiddenEntityTypeIds = entityTypeIds.filter( + (entityTypeId) => !instantiableEntityTypeIds.has(entityTypeId), + ); + + if (forbiddenEntityTypeIds.length > 0) { + return { + code: StatusCode.PermissionDenied, + message: + forbiddenEntityTypeIds.length === 1 + ? `Entity type ${forbiddenEntityTypeIds[0]} doesn't exist or can't be instantiated by the user running the flow.` + : `Entity types ${forbiddenEntityTypeIds.join(", ")} don't exist or can't be instantiated by the user running the flow.`, + contents: [], + }; + } + + const savingActorId = await getSavingActorId({ + actorId: userAuthentication.actorId, + webId, + isAiGenerated: false, + }); + + /* + * The same for every attempt of the step in this run, including Temporal's retries, so that an attempt after one + * that created the entity finds it rather than creating a second one. + */ + const entityUuid = generateUuidFromName( + `${runId}/${stepId}`, + createdEntityUuidNamespace, + ) as EntityUuid; + + let entity: HashEntity; + + try { + entity = await retrySave(async () => { + const { + entities: [created], + } = await queryEntities( + { graphApi: graphApiClient }, + { actorId: savingActorId }, + { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + return ( + created ?? + HashEntity.create( + graphApiClient, + { actorId: savingActorId }, + { + webId, + entityUuid, + entityTypeIds: [firstEntityTypeId, ...otherEntityTypeIds], + properties, + draft, + provenance: { + actorType: "machine", + origin: { type: "flow", id: flowEntityId, stepIds: [stepId] }, + }, + }, + ) + ); + }); + } catch (error) { + return { + code: StatusCode.Internal, + message: `Could not create entity: ${extractErrorMessage(error)}`, + contents: [], + }; + } + + return { + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entity.entityId }, + }, + ], + }, + ], + }; +}; diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts index a35c5b6acc2..a2c4e6183aa 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entity-action.ts @@ -1,12 +1,10 @@ import { Context } from "@temporalio/activity"; -import { backOff } from "exponential-backoff"; import { extractEntityUuidFromEntityId } from "@blockprotocol/type-system"; import { getStorageProvider, resolvePayloadValue, } from "@local/hash-backend-utils/flows/payload-storage"; -import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; import { HashEntity, HashLinkEntity, @@ -16,7 +14,6 @@ import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flow import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { StatusCode } from "@local/status"; -import { getAiAssistantAccountIdActivity } from "../get-ai-assistant-account-id-activity.js"; import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; import { createInferredEntityNotification } from "../shared/create-inferred-entity-notification.js"; import { @@ -31,6 +28,7 @@ import { getEntityUpdate, getLatestEntityById, } from "./shared/graph-requests.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; import type { MatchedEntityUpdate } from "../shared/match-existing-entity.js"; import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; @@ -101,30 +99,11 @@ export const persistEntity = async ({ const isAiGenerated = provenance.actorType === "ai"; - const webBotActorId = isAiGenerated - ? await getAiAssistantAccountIdActivity({ - authentication: { actorId }, - graphApiClient, - grantCreatePermissionForWeb: webId, - }) - : await getWebMachineId( - { graphApi: graphApiClient }, - { actorId }, - { webId }, - ).then((maybeMachineId) => { - if (!maybeMachineId) { - throw new Error( - `Failed to get web bot account ID for web ID: ${webId}`, - ); - } - return maybeMachineId; - }); - - if (!webBotActorId) { - throw new Error( - `Could not get ${isAiGenerated ? "AI" : "web"} bot for web ${webId}`, - ); - } + const webBotActorId = await getSavingActorId({ + actorId, + webId, + isAiGenerated, + }); /** * @todo: determine whether the entity type ID is a file entity type ID @@ -206,47 +185,35 @@ export const persistEntity = async ({ */ const stableReferenceToMatchedEntity = matchedEntityUpdate; - entity = await backOff( - () => - stableReferenceToMatchedEntity.existingEntity.patch( - graphApiClient, - { actorId: webBotActorId }, - { - entityTypeIds: - stableReferenceToMatchedEntity.newValues.entityTypeIds, - draft: existingEntityIsDraft ? true : createEditionAsDraft, - propertyPatches: patchOperations, - provenance: { - ...entityValues.provenance, - sources: - stableReferenceToMatchedEntity.newValues.editionSources, - }, + entity = await retrySave(() => + stableReferenceToMatchedEntity.existingEntity.patch( + graphApiClient, + { actorId: webBotActorId }, + { + entityTypeIds: + stableReferenceToMatchedEntity.newValues.entityTypeIds, + draft: existingEntityIsDraft ? true : createEditionAsDraft, + propertyPatches: patchOperations, + provenance: { + ...entityValues.provenance, + sources: + stableReferenceToMatchedEntity.newValues.editionSources, }, - ), - { - jitter: "full", - numOfAttempts: 3, - startingDelay: 1_000, - }, + }, + ), ); } else { - entity = await backOff( - () => - HashEntity.create( - graphApiClient, - { actorId: webBotActorId }, - { - ...entityValues, - draft: createEditionAsDraft, - entityUuid, - webId, - }, - ), - { - jitter: "full", - numOfAttempts: 3, - startingDelay: 1_000, - }, + entity = await retrySave(() => + HashEntity.create( + graphApiClient, + { actorId: webBotActorId }, + { + ...entityValues, + draft: createEditionAsDraft, + entityUuid, + webId, + }, + ), ); } } catch (err) { diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts new file mode 100644 index 00000000000..46367359c18 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/shared/save-entity.ts @@ -0,0 +1,62 @@ +import { backOff } from "exponential-backoff"; + +import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; + +import { getAiAssistantAccountIdActivity } from "../../get-ai-assistant-account-id-activity.js"; +import { graphApiClient } from "../../shared/graph-api-client.js"; + +import type { + AiId, + MachineId, + UserId, + WebId, +} from "@blockprotocol/type-system"; + +/** + * The bot a flow saves entities in a web as, on behalf of the user running it: the AI assistant for entities an AI + * proposed, otherwise the web's machine. + */ +export const getSavingActorId = async ({ + actorId, + webId, + isAiGenerated, +}: { + actorId: UserId; + webId: WebId; + isAiGenerated: boolean; +}): Promise => { + const botActorId = isAiGenerated + ? await getAiAssistantAccountIdActivity({ + authentication: { actorId }, + graphApiClient, + grantCreatePermissionForWeb: webId, + }) + : await getWebMachineId( + { graphApi: graphApiClient }, + { actorId }, + { webId }, + ).then((maybeMachineId) => { + if (!maybeMachineId) { + throw new Error( + `Failed to get web bot account ID for web ID: ${webId}`, + ); + } + return maybeMachineId; + }); + + if (!botActorId) { + throw new Error( + `Could not get ${isAiGenerated ? "AI" : "web"} bot for web ${webId}`, + ); + } + + return botActorId; +}; + +/** Saves an entity, retrying a failed attempt twice. */ +export const retrySave = (save: () => Promise): Promise => + backOff(save, { + jitter: "full", + numOfAttempts: 3, + startingDelay: 1_000, + }); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts new file mode 100644 index 00000000000..38f7eecea0a --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.test.ts @@ -0,0 +1,322 @@ +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 { updateEntityAction } from "./update-entity-action.js"; + +import type { + BaseUrl, + EntityId, + PropertyPatchOperation, + VersionedUrl, + 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 { + PayloadValue, + StepInput, +} from "@local/hash-isomorphic-utils/flows/types"; + +const workflowId = "workflow"; +const webId = "00000000-0000-4000-8000-000000000001" as WebId; +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({ + flowEntityId: "flow", + stepId: "update", + userAuthentication: { actorId: "user" }, + webId, + workflowId, + }), +})); +vi.mock( + "@local/hash-backend-utils/flows/payload-storage", + async (importOriginal) => ({ + ...(await importOriginal()), + getStorageProvider: () => storageProvider, + }), +); +vi.mock("./shared/save-entity.js", () => ({ + getSavingActorId: () => Promise.resolve("bot"), + retrySave: (save: () => unknown) => save(), +})); +vi.mock("@local/hash-graph-sdk/entity", async (importOriginal) => ({ + ...(await importOriginal()), + queryEntities: vi.fn(), +})); + +const nameUrl = "https://example.com/types/property-type/name/" as BaseUrl; +const emailUrl = "https://example.com/types/property-type/email/" as BaseUrl; +const text = { + dataTypeId: "https://example.com/types/data-type/text/v/1" as VersionedUrl, +}; + +const setName = { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: text }, +} satisfies PropertyPatchOperation; + +const entityIdOf = (index: number, draft = false) => + `${webId}~00000000-0000-4000-8000-${String(index).padStart(12, "0")}${ + draft ? "~00000000-0000-4000-8000-0000000000ff" : "" + }` as EntityId; + +const patch = vi.fn(); + +/** Answers the graph query with an entity with the given ID, if one is given, and whether the user can edit it. */ +const mockEntity = (entityId: EntityId | null, { canEdit = true } = {}) => + vi.mocked(queryEntities).mockResolvedValueOnce({ + permissions: + entityId && canEdit ? { [entityId]: { update: ["edition"] } } : {}, + entities: entityId ? [{ entityId, patch }] : [], + cursor: null, + } as unknown as QueryEntitiesResponse); + +const run = (params: { + entityId: PayloadValue<"EntityId", false>; + propertyPatches: PayloadValue<"PropertyPatch", true>; +}) => + updateEntityAction({ + inputs: [ + { + inputName: "entityId", + payload: { kind: "EntityId", value: params.entityId }, + }, + { + inputName: "propertyPatches", + payload: { kind: "PropertyPatch", value: params.propertyPatches }, + }, + ] satisfies StepInput[], + }); + +const updated = (entityId: EntityId) => ({ + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: entityId }, + }, + ], + }, + ], +}); + +describe("updateEntityAction", () => { + beforeEach(() => { + vi.mocked(queryEntities).mockReset(); + patch.mockReset().mockResolvedValue({}); + }); + + it("applies the patches as given, as the web's machine, and outputs the same ID", async () => { + mockEntity(entityIdOf(1)); + + const propertyPatches = [ + setName, + { + op: "replace", + path: [emailUrl], + property: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + { + op: "remove", + path: ["https://example.com/types/property-type/age/" as BaseUrl], + }, + ] satisfies PropertyPatchOperation[]; + + expect(await run({ entityId: entityIdOf(1), propertyPatches })).toEqual( + updated(entityIdOf(1)), + ); + + expect(vi.mocked(queryEntities).mock.calls[0]?.[1]).toEqual({ + actorId: "user", + }); + expect(patch).toHaveBeenCalledWith( + {}, + { actorId: "bot" }, + { + propertyPatches, + draft: false, + provenance: { + actorType: "machine", + origin: { type: "flow", id: "flow", stepIds: ["update"] }, + }, + }, + ); + }); + + it("updates the draft an entity ID with a draft ID names, which stays a draft", async () => { + mockEntity(entityIdOf(1, true)); + + expect( + await run({ entityId: entityIdOf(1, true), propertyPatches: [setName] }), + ).toEqual(updated(entityIdOf(1, true))); + + const query = vi.mocked(queryEntities).mock.calls[0]?.[2]; + expect(query?.includeDrafts).toBe(true); + expect(query?.includePermissions).toBe(true); + expect(query?.filter).toMatchObject({ + all: expect.arrayContaining([ + { + equal: [ + { path: ["draftId"] }, + { parameter: "00000000-0000-4000-8000-0000000000ff" }, + ], + }, + ]) as unknown[], + }); + expect(patch).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ draft: true }), + ); + }); + + it("updates 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)], + }); + + mockEntity(entityIdOf(2)); + + expect( + await run({ + entityId: { + __stored: true, + kind: "EntityId", + array: false, + of: stored, + index: 1, + }, + propertyPatches: [setName], + }), + ).toEqual(updated(entityIdOf(2))); + }); + + it.each([ + { + name: "an entity ID that isn't valid", + entityId: "" as EntityId, + propertyPatches: [setName], + message: " is not a valid EntityId.", + }, + { + name: "an entity in another web", + entityId: + "00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001" as EntityId, + propertyPatches: [setName], + message: + "Entity 00000000-0000-4000-8000-0000000000aa~00000000-0000-4000-8000-000000000001 isn't in the web the flow runs in, so it can't be updated.", + }, + { + name: "no patches", + entityId: entityIdOf(1), + propertyPatches: [], + message: "Give at least one property patch.", + }, + { + name: "a patch inside a property's value", + entityId: entityIdOf(1), + propertyPatches: [{ op: "remove", path: [emailUrl, 0] }], + message: `Property patch 1 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, + }, + { + name: "two patches to the same property, which fail if applied again", + entityId: entityIdOf(1), + propertyPatches: [ + { ...setName, op: "replace" }, + { op: "remove", path: [nameUrl] }, + ], + message: `Property patch 2 changes property ${nameUrl} again: give one patch per property.`, + }, + { + name: "a value without a data type ID", + entityId: entityIdOf(1), + propertyPatches: [ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: { dataTypeId: null } }, + }, + ], + message: `Property ${nameUrl} must give the data type ID of each of its values.`, + }, + ] satisfies { + name: string; + entityId: EntityId; + propertyPatches: PropertyPatchOperation[]; + message: string; + }[])( + "fails for $name, without reading or updating the entity", + async ({ entityId, propertyPatches, message }) => { + expect(await run({ entityId, propertyPatches })).toEqual({ + code: StatusCode.InvalidArgument, + message, + contents: [], + }); + expect(queryEntities).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + }, + ); + + it("fails for an entity that is missing or the user can't read", async () => { + mockEntity(null); + + expect( + await run({ entityId: entityIdOf(1), propertyPatches: [setName] }), + ).toEqual({ + code: StatusCode.NotFound, + message: `Entity ${entityIdOf(1)} doesn't exist or can't be read by the user running the flow.`, + contents: [], + }); + }); + + it("fails for an entity the user can read but not edit, without updating it", async () => { + mockEntity(entityIdOf(1), { canEdit: false }); + + expect( + await run({ entityId: entityIdOf(1), propertyPatches: [setName] }), + ).toEqual({ + code: StatusCode.PermissionDenied, + message: `The user running the flow can't edit entity ${entityIdOf(1)}.`, + contents: [], + }); + expect(patch).not.toHaveBeenCalled(); + }); + + it("fails if the graph doesn't save the update, e.g. because the entity it leaves doesn't fit its types", async () => { + mockEntity(entityIdOf(1)); + patch.mockRejectedValueOnce(new Error("Entity validation failed")); + + const { code, message } = await run({ + entityId: entityIdOf(1), + propertyPatches: [setName], + }); + + expect(code).toBe(StatusCode.Internal); + expect(message).toContain(`Could not update entity ${entityIdOf(1)}`); + expect(message).toContain("Entity validation failed"); + }); +}); diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts new file mode 100644 index 00000000000..b9090a52db9 --- /dev/null +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/update-entity-action.ts @@ -0,0 +1,146 @@ +import { isEntityId, splitEntityId } from "@blockprotocol/type-system"; +import { + getStorageProvider, + resolvePayloadValue, +} from "@local/hash-backend-utils/flows/payload-storage"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { getSimplifiedFlowActionInputs } from "@local/hash-isomorphic-utils/flows/action-definitions"; +import { validatePropertyPatches } from "@local/hash-isomorphic-utils/flows/property-values"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { StatusCode } from "@local/status"; + +import { extractErrorMessage } from "../infer-entities/shared/extract-validation-failure-details.js"; +import { getFlowContext } from "../shared/get-flow-context.js"; +import { graphApiClient } from "../shared/graph-api-client.js"; +import { getSavingActorId, retrySave } from "./shared/save-entity.js"; + +import type { FlowActionActivity } from "@local/hash-backend-utils/flows"; + +const invalidArgument = (message: string) => ({ + code: StatusCode.InvalidArgument, + message, + contents: [], +}); + +/** + * Applies property patches to an entity the user running the flow can edit, leaving its other properties as they are. + * The graph validates the entity the patches leave. An entity ID with a draft ID updates that draft, which stays a + * draft. + */ +export const updateEntityAction: FlowActionActivity<"updateEntity"> = async ({ + inputs, +}) => { + const { entityId, propertyPatches } = getSimplifiedFlowActionInputs({ + inputs, + actionType: "updateEntity", + }); + + const patchesError = validatePropertyPatches(propertyPatches); + + if (patchesError) { + return invalidArgument(patchesError); + } + + const { flowEntityId, stepId, userAuthentication, webId, 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, + ); + + if (!isEntityId(resolvedEntityId)) { + return invalidArgument( + `${String(resolvedEntityId)} is not a valid EntityId.`, + ); + } + + const [entityWebId, entityUuid, draftId] = splitEntityId(resolvedEntityId); + + if (entityWebId !== webId) { + return invalidArgument( + `Entity ${resolvedEntityId} isn't in the web the flow runs in, so it can't be updated.`, + ); + } + + const { + entities: [existingEntity], + permissions, + } = await queryEntities({ graphApi: graphApiClient }, userAuthentication, { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: entityWebId }] }, + draftId + ? { equal: [{ path: ["draftId"] }, { parameter: draftId }] } + : { not: { exists: { path: ["draftId"] } } }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: !!draftId, + includePermissions: true, + }); + + if (!existingEntity) { + return { + code: StatusCode.NotFound, + message: `Entity ${resolvedEntityId} doesn't exist or can't be read by the user running the flow.`, + contents: [], + }; + } + + /* The web's machine saves the update, so the user's own permission to edit the entity is checked here. */ + if (!permissions?.[existingEntity.entityId]?.update) { + return { + code: StatusCode.PermissionDenied, + message: `The user running the flow can't edit entity ${resolvedEntityId}.`, + contents: [], + }; + } + + const savingActorId = await getSavingActorId({ + actorId: userAuthentication.actorId, + webId, + isAiGenerated: false, + }); + + try { + await retrySave(() => + existingEntity.patch( + graphApiClient, + { actorId: savingActorId }, + { + propertyPatches, + draft: !!draftId, + provenance: { + actorType: "machine", + origin: { type: "flow", id: flowEntityId, stepIds: [stepId] }, + }, + }, + ), + ); + } catch (error) { + return { + code: StatusCode.Internal, + message: `Could not update entity ${resolvedEntityId}: ${extractErrorMessage(error)}`, + contents: [], + }; + } + + return { + code: StatusCode.Ok, + contents: [ + { + outputs: [ + { + outputName: "entityId", + payload: { kind: "EntityId", value: resolvedEntityId }, + }, + ], + }, + ], + }; +}; 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..bc0df67eaf3 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 @@ -20,6 +20,8 @@ const unsupportedPayloadKinds = [ "FailedEntityProposal", "FilterCondition", "PersistedEntityMetadata", + "PropertyObjectWithMetadata", + "PropertyPatch", "ProposedEntity", "ProposedEntityWithResolvedLinks", "SortKey", diff --git a/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts b/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts index 2f463792f4e..054c4c381d5 100644 --- a/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts +++ b/libs/@local/flow-petri-net/src/compile-flow-definition/shared/colors.ts @@ -63,6 +63,8 @@ const payloadKindDisplayColors: Record = { PersistedEntityMetadata: "#10B981", ProposedEntity: "#22C55E", ProposedEntityWithResolvedLinks: "#16A34A", + PropertyObjectWithMetadata: "#15803D", + PropertyPatch: "#0D9488", FailedEntityProposal: "#B91C1C", Boolean: "#F59E0B", Number: "#EA580C", 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..7245310d250 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,10 @@ import { checkAggregationInput } from "./aggregation.js"; import { checkConditionInput, filterableKinds } from "./filter-condition.js"; import { maxEntitiesPerStep } from "./limits.js"; +import { + checkPropertiesWithMetadataInput, + checkPropertyPatchesInput, +} from "./property-values.js"; import { checkSortKeyInput } from "./sort-key.js"; import { checkTakeCountInput } from "./take-count.js"; import { allPayloadKinds } from "./types.js"; @@ -25,6 +29,7 @@ export type AiFlowActionDefinitionId = | "aggregate" | "analyzeEntityData" | "answerQuestion" + | "createEntity" | "filterList" | "find" | "generateChartConfig" @@ -44,6 +49,7 @@ export type AiFlowActionDefinitionId = | "queryEntities" | "researchEntities" | "takeFirst" + | "updateEntity" | "webSearch" | "writeGoogleSheet"; @@ -1031,6 +1037,87 @@ const aiFlowActionDefinitionsAsConst = { }, ], }, + createEntity: { + actionDefinitionId: "createEntity", + name: "Create Entity", + description: + "Create an entity of one or more types in the web the flow runs in, with values for some of its properties.", + kind: "action", + inputs: [ + { + oneOfPayloadKinds: ["VersionedUrl"], + name: "entityTypeIds", + description: "The types of the entity to create.", + required: true, + array: true, + }, + { + oneOfPayloadKinds: ["PropertyObjectWithMetadata"], + name: "properties", + description: + "The entity's properties with their metadata, as the graph takes them: values keyed by property type base URL, each giving the ID of its data type and optionally its provenance and confidence.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["Boolean"], + name: "draft", + description: + "Whether to create the entity as a draft, whose required properties can be left out.", + required: false, + default: { + kind: "Boolean", + value: false, + }, + array: false, + }, + ], + outputs: [ + { + payloadKind: "EntityId", + name: "entityId", + description: "The new entity, with a draft ID if it's a draft.", + array: false, + required: true, + }, + ], + check: checkPropertiesWithMetadataInput, + }, + updateEntity: { + actionDefinitionId: "updateEntity", + name: "Update Entity", + description: + "Set or remove some of the properties of an entity in the web the flow runs in, leaving its other properties as they are.", + kind: "action", + inputs: [ + { + oneOfPayloadKinds: ["EntityId"], + name: "entityId", + description: + "The entity to update: the draft for an entity ID with a draft ID, otherwise the live entity.", + required: true, + array: false, + }, + { + oneOfPayloadKinds: ["PropertyPatch"], + name: "propertyPatches", + description: + "The changes to make, in order, as the graph takes them. Each changes the whole property at its path, a property type base URL, and no two change the same property: add sets it, replacing any value it has; replace changes one the entity has; remove removes it if the entity has it. Added and replaced values give the ID of their data type.", + required: true, + array: true, + }, + ], + outputs: [ + { + payloadKind: "EntityId", + name: "entityId", + description: "The entity updated: the same entity ID as given.", + array: false, + required: true, + }, + ], + check: checkPropertyPatchesInput, + }, takeFirst: { actionDefinitionId: "takeFirst", name: "Take First", diff --git a/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts new file mode 100644 index 00000000000..364fcc607aa --- /dev/null +++ b/libs/@local/hash-isomorphic-utils/src/flows/property-values.ts @@ -0,0 +1,178 @@ +import { + validateBaseUrl, + validateVersionedUrl, +} from "@blockprotocol/type-system"; + +import type { ActionStepCheck } from "./types.js"; +import type { + BaseUrl, + PropertyObjectWithMetadata, + PropertyPatchOperation, +} from "@blockprotocol/type-system"; + +/* + * Properties are given in the graph's own terms, so that the graph validates them when it saves the entity. Only their + * shape is checked here, because flows are validated without access to the ontology. + */ + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isPropertyBaseUrl = (value: unknown): value is BaseUrl => + typeof value === "string" && validateBaseUrl(value).type === "Ok"; + +/** + * Whether a property gives the data type ID of each of its values, including in lists and property objects. Without + * one, the graph infers it, which it can't do for a property that allows more than one data type, or only an abstract + * one. As in the graph, a value is told apart from a list or property object by its metadata having a data type ID. + */ +const hasDataTypeIds = (property: unknown): boolean => { + if (!isRecord(property)) { + return false; + } + + const { value, metadata } = property; + + if (isRecord(metadata) && Object.hasOwn(metadata, "dataTypeId")) { + return ( + typeof metadata.dataTypeId === "string" && + validateVersionedUrl(metadata.dataTypeId).type === "Ok" + ); + } + + if (Array.isArray(value)) { + return value.every(hasDataTypeIds); + } + + return isRecord(value) && Object.values(value).every(hasDataTypeIds); +}; + +const notABaseUrlMessage = (value: unknown) => + `${JSON.stringify(value)} is not a property type base URL.`; + +const missingDataTypeIdsMessage = (baseUrl: BaseUrl) => + `Property ${baseUrl} must give the data type ID of each of its values.`; + +/** Checks the shape of an entity's properties with their metadata, returning why it's invalid. */ +export const validatePropertiesWithMetadata = ( + properties: PropertyObjectWithMetadata, +): string | undefined => { + /* Persisted definitions and flow inputs may be malformed. */ + if (!isRecord(properties) || !isRecord(properties.value)) { + return "Properties must be an object whose value is keyed by property type base URL."; + } + + for (const [baseUrl, property] of Object.entries(properties.value)) { + if (!isPropertyBaseUrl(baseUrl)) { + return notABaseUrlMessage(baseUrl); + } + + if (!hasDataTypeIds(property)) { + return missingDataTypeIdsMessage(baseUrl); + } + } + + return undefined; +}; + +const patchOps: PropertyPatchOperation["op"][] = ["add", "remove", "replace"]; + +/** + * Checks the shape of patches to an entity's properties, returning why they're invalid. Each patch changes a whole + * property, and no two change the same one, so that applying them again if the step is retried leaves the same + * properties: a patch inside a list could add an item twice, and replacing a property then removing it fails once + * it's gone. + */ +export const validatePropertyPatches = ( + patches: PropertyPatchOperation[], +): string | undefined => { + /* Persisted definitions and flow inputs may be malformed. */ + if (!Array.isArray(patches)) { + return "Property patches must be a list."; + } + + if (patches.length === 0) { + return "Give at least one property patch."; + } + + const patchedBaseUrls = new Set(); + + for (const [index, patch] of patches.entries()) { + const position = `Property patch ${index + 1}`; + + if (!isRecord(patch) || !patchOps.includes(patch.op)) { + return `${position} must have an op of ${patchOps.join(", ")}.`; + } + + const { path } = patch; + const baseUrl: unknown = Array.isArray(path) ? path[0] : undefined; + + if (!isPropertyBaseUrl(baseUrl)) { + return `${position} must have a path of a property type base URL.`; + } + + if (path.length > 1) { + return `${position} changes part of property ${baseUrl}, which isn't supported yet: give its whole value.`; + } + + if (patchedBaseUrls.has(baseUrl)) { + return `${position} changes property ${baseUrl} again: give one patch per property.`; + } + + patchedBaseUrls.add(baseUrl); + + if (patch.op !== "remove" && !hasDataTypeIds(patch.property)) { + return missingDataTypeIdsMessage(baseUrl); + } + } + + return undefined; +}; + +/** Checks a Create entity step's constant `properties` input when the flow is defined. */ +export const checkPropertiesWithMetadataInput: ActionStepCheck = ({ + step, + report, +}) => { + const { properties } = step.inputs; + + /* A constant of another kind is reported as an incompatible connection. */ + if ( + properties?.kind !== "constant" || + properties.payload.kind !== "PropertyObjectWithMetadata" + ) { + return; + } + + const error = validatePropertiesWithMetadata( + properties.payload.value as PropertyObjectWithMetadata, + ); + + if (error) { + report("properties", error); + } +}; + +/** Checks an Update entity step's constant `propertyPatches` input when the flow is defined. */ +export const checkPropertyPatchesInput: ActionStepCheck = ({ + step, + report, +}) => { + const { propertyPatches } = step.inputs; + + /* A constant of another kind is reported as an incompatible connection. */ + if ( + propertyPatches?.kind !== "constant" || + propertyPatches.payload.kind !== "PropertyPatch" + ) { + return; + } + + const error = validatePropertyPatches( + propertyPatches.payload.value as PropertyPatchOperation[], + ); + + if (error) { + report("propertyPatches", error); + } +}; diff --git a/libs/@local/hash-isomorphic-utils/src/flows/types.ts b/libs/@local/hash-isomorphic-utils/src/flows/types.ts index ae428233275..0531ff3d599 100644 --- a/libs/@local/hash-isomorphic-utils/src/flows/types.ts +++ b/libs/@local/hash-isomorphic-utils/src/flows/types.ts @@ -15,6 +15,8 @@ import type { EntityUuid, PropertyObject, PropertyObjectMetadata, + PropertyObjectWithMetadata, + PropertyPatchOperation, ProvidedEntityEditionProvenance, Url, VersionedUrl, @@ -133,6 +135,10 @@ export type PayloadKindValues = { GoogleSheet: GoogleSheet; Number: number; PersistedEntityMetadata: PersistedEntityMetadata; + /** An entity's properties with their metadata, as the graph takes them to create an entity */ + PropertyObjectWithMetadata: PropertyObjectWithMetadata; + /** A change to one of an entity's properties, as the graph takes it to update an entity */ + PropertyPatch: PropertyPatchOperation; ProposedEntity: ProposedEntity; ProposedEntityWithResolvedLinks: ProposedEntityWithResolvedLinks; SortKey: SortKey; @@ -186,6 +192,8 @@ export const payloadKindTraits = { GoogleSheet: { primitive: null, fields: true }, Number: { primitive: "number" }, PersistedEntityMetadata: { primitive: null, fields: true }, + PropertyObjectWithMetadata: { primitive: null }, + PropertyPatch: { primitive: null }, ProposedEntity: { primitive: null, fields: true, entity: true }, ProposedEntityWithResolvedLinks: { primitive: null, 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 caab3500171..ab39150875e 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 @@ -794,6 +794,242 @@ describe("validateFlowDefinition", () => { }); }); + describe("properties of createEntity steps", () => { + const nameUrl = "https://example.com/types/property-type/name/"; + const emailUrl = "https://example.com/types/property-type/email/"; + const text = { dataTypeId: "https://example.com/types/data-type/text/v/1" }; + + const errorsOf = (properties: unknown) => + validateFlowDefinition({ + ...baseDefinition, + inputs: [ + { + name: "properties", + payloadKind: "PropertyObjectWithMetadata", + array: false, + required: true, + }, + ], + steps: [ + { + kind: "action", + stepId: "create", + actionDefinitionId: "createEntity", + description: "", + inputs: { + entityTypeIds: { + kind: "constant", + payload: { + kind: "VersionedUrl", + value: ["https://example.com/types/entity-type/person/v/1"], + }, + }, + properties, + }, + }, + ], + }).diagnostics.filter(({ severity }) => severity === "error"); + + const constant = (value: unknown) => ({ + kind: "constant", + payload: { kind: "PropertyObjectWithMetadata", value }, + }); + + it.each([ + { + name: "a constant with a data type ID for each value, including in a list", + properties: constant({ + value: { + [nameUrl]: { value: "Ada", metadata: text }, + [emailUrl]: { + value: [{ value: "ada@example.com", metadata: text }], + }, + }, + }), + }, + { + name: "a constant with no properties", + properties: constant({ value: {} }), + }, + { + name: "a flow input, which is checked when the step runs", + properties: { kind: "flow-input", inputName: "properties" }, + }, + ])("accepts $name", ({ properties }) => { + expect(errorsOf(properties)).toEqual([]); + }); + + it.each([ + { + name: "properties without metadata", + value: { [nameUrl]: "Ada" }, + message: + "Properties must be an object whose value is keyed by property type base URL.", + }, + { + name: "a property type versioned URL rather than a base URL", + value: { + value: { [`${nameUrl}v/1`]: { value: "Ada", metadata: text } }, + }, + message: `"${nameUrl}v/1" is not a property type base URL.`, + }, + { + name: "a value without a data type ID", + value: { + value: { + [nameUrl]: { value: "Ada", metadata: { dataTypeId: null } }, + }, + }, + message: `Property ${nameUrl} must give the data type ID of each of its values.`, + }, + { + name: "an item of a list without a data type ID", + value: { + value: { [emailUrl]: { value: [{ value: "ada@example.com" }] } }, + }, + message: `Property ${emailUrl} must give the data type ID of each of its values.`, + }, + ])("rejects $name", ({ value, message }) => { + expect(errorsOf(constant(value))).toEqual([ + { + severity: "error", + code: "invalidInput", + message, + path: ["steps", 0, "inputs", "properties"], + }, + ]); + }); + }); + + describe("property patches of updateEntity steps", () => { + const nameUrl = "https://example.com/types/property-type/name/"; + const emailUrl = "https://example.com/types/property-type/email/"; + const text = { dataTypeId: "https://example.com/types/data-type/text/v/1" }; + + const errorsOf = (propertyPatches: unknown) => + validateFlowDefinition({ + ...baseDefinition, + inputs: [ + { + name: "person", + payloadKind: "EntityId", + array: false, + required: true, + }, + { + name: "patches", + payloadKind: "PropertyPatch", + array: true, + required: true, + }, + ], + steps: [ + { + kind: "action", + stepId: "update", + actionDefinitionId: "updateEntity", + description: "", + inputs: { + entityId: { kind: "flow-input", inputName: "person" }, + propertyPatches, + }, + }, + ], + }).diagnostics.filter(({ severity }) => severity === "error"); + + const constant = (value: unknown) => ({ + kind: "constant", + payload: { kind: "PropertyPatch", value }, + }); + + it.each([ + { + name: "constant patches that add, replace and remove whole properties", + propertyPatches: constant([ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: text }, + }, + { + op: "replace", + path: [emailUrl], + property: { value: [{ value: "ada@example.com", metadata: text }] }, + }, + { + op: "remove", + path: ["https://example.com/types/property-type/age/"], + }, + ]), + }, + { + name: "a flow input, which is checked when the step runs", + propertyPatches: { kind: "flow-input", inputName: "patches" }, + }, + ])("accepts $name", ({ propertyPatches }) => { + expect(errorsOf(propertyPatches)).toEqual([]); + }); + + it.each([ + { + name: "no patches", + value: [], + message: "Give at least one property patch.", + }, + { + name: "an op the graph doesn't have", + value: [{ op: "move", path: [nameUrl] }], + message: "Property patch 1 must have an op of add, remove, replace.", + }, + { + name: "a path of a property type versioned URL", + value: [{ op: "remove", path: [`${nameUrl}v/1`] }], + message: + "Property patch 1 must have a path of a property type base URL.", + }, + { + name: "a path inside a property's value", + value: [ + { op: "remove", path: [nameUrl] }, + { op: "remove", path: [emailUrl, 0] }, + ], + message: `Property patch 2 changes part of property ${emailUrl}, which isn't supported yet: give its whole value.`, + }, + { + name: "two patches to the same property, which fail if applied again", + value: [ + { + op: "replace", + path: [nameUrl], + property: { value: "Ada", metadata: text }, + }, + { op: "remove", path: [nameUrl] }, + ], + message: `Property patch 2 changes property ${nameUrl} again: give one patch per property.`, + }, + { + name: "a value without a data type ID", + value: [ + { + op: "add", + path: [nameUrl], + property: { value: "Ada", metadata: { dataTypeId: null } }, + }, + ], + message: `Property ${nameUrl} must give the data type ID of each of its values.`, + }, + ])("rejects $name", ({ value, message }) => { + expect(errorsOf(constant(value))).toEqual([ + { + severity: "error", + code: "invalidInput", + message, + path: ["steps", 0, "inputs", "propertyPatches"], + }, + ]); + }); + }); + describe("aggregations of aggregate steps", () => { const aggregateDefinition = (aggregation: Record) => ({ ...baseDefinition, diff --git a/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts b/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts index 29c60045acd..ed1ec859c68 100644 --- a/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts +++ b/libs/@local/hash-isomorphic-utils/src/generate-uuid.ts @@ -1,3 +1,7 @@ -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, v5 as uuidv5 } from "uuid"; export const generateUuid = () => uuidv4(); + +/** The same UUID for the same name in the same namespace, which must itself be a UUID. */ +export const generateUuidFromName = (name: string, namespace: string) => + uuidv5(name, namespace);