From add91ff048dc8db05c42ed2d4e4775ea162d13b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 17 Oct 2026 13:52:47 +0200 Subject: [PATCH] issue 976: the forge's announcements get a deterministic message id; the announcement files go, the poll's watermark is kept in the module's state --- modules/gitea/announce.ts | 84 ++++++++++++++++ modules/gitea/index.ts | 97 ++++++++----------- modules/gitea/internal/busdedup/dedup_test.go | 82 ++++++++++++++++ modules/gitea/merge.ts | 4 +- modules/gitea/module.json | 3 +- modules/gitea/test/announce.test.ts | 63 ++++++++++++ 6 files changed, 274 insertions(+), 59 deletions(-) create mode 100644 modules/gitea/announce.ts create mode 100644 modules/gitea/internal/busdedup/dedup_test.go create mode 100644 modules/gitea/test/announce.test.ts diff --git a/modules/gitea/announce.ts b/modules/gitea/announce.ts new file mode 100644 index 00000000..19293af5 --- /dev/null +++ b/modules/gitea/announce.ts @@ -0,0 +1,84 @@ +// **The forge's announcements carry a message id** (novox/hq ADR 0406 §6 and §7, issue 976). A merge, a new head and a +// close are announced by more than one path — the poll, the forge's own event, the merge tool's ask of a check — and +// were kept from being announced twice by files of what had been announced. They are now announced with a message id +// that is the same for the same announcement, whichever path makes it: the runtime makes an event's `x-event-id` header +// the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has +// seen the id of, within its de-duplication window, once. +// +// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given. +// It returns to `emit` once the SDK takes an id. +// +// The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked +// again against a new trunk commit is another announcement), a close by its head. + +import { broker } from "@novox/mesh-sdk/messaging"; +import { state as keptState } from "@novox/mesh-sdk/state"; + +type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string }; + +/** The message id of an announcement, or undefined for an event that is not one of these three. */ +export function announcementId(type: string, body: unknown): string | undefined { + const b = (body ?? {}) as Said; + const pull = `${b.owner}/${b.repo}#${b.number}`; + switch (type) { + case "pull.merged": + return `merged:${pull}@${b.merge_commit_sha ?? ""}`; + case "pull.updated": + return `updated:${pull}@${b.head_sha ?? ""}:${b.base_sha ?? ""}`; + case "pull.closed": + return `closed:${pull}@${b.head_sha ?? ""}`; + default: + return undefined; + } +} + +/** Announce a merge, a head or a close: published with its message id. */ +export async function announce(type: string, body: unknown): Promise { + const id = announcementId(type, body); + if (!id) throw new Error(`${type} is not an announcement with a message id`); + const node = process.env.MESH_NODE ?? "unknown"; + await broker().publish({ + key: type, + node, + body, + headers: { + "x-event-id": id, + "x-source": process.env.MESH_MODULE ?? "unknown", + "x-node": node, + "x-time": new Date().toISOString(), + "content-type": "application/json", + }, + }); +} + +// --- the watermark: the one value of the old files that is not the bus's to remember -------------------------------- + +/** What the module keeps of how far its poll of merges has looked: a time, in its own state. */ +interface Kept { + get(key: string): Promise<{ value: unknown } | null>; + put(key: string, value: unknown): Promise; +} + +const WATERMARK = "merges-since"; + +/** The time the poll of merges last looked from, or "" when none is kept or the state cannot be reached: a merge made + * before it is history, one after it is announced. */ +export async function readWatermark(state: Kept = keptState("announced")): Promise { + try { + const entry = await state.get(WATERMARK); + const since = (entry?.value as { since?: string } | undefined)?.since; + return typeof since === "string" ? since : ""; + } catch (err) { + console.error(`[gitea] where the poll of merges last looked from could not be read, so none is kept — ${err instanceof Error ? err.message : err}`); + return ""; + } +} + +/** Keep the time the poll of merges looked from; a state that cannot be reached is said, and the next pass keeps it. */ +export async function keepWatermark(since: string, state: Kept = keptState("announced")): Promise { + try { + await state.put(WATERMARK, { since }); + } catch (err) { + console.error(`[gitea] where the poll of merges looked from could not be kept — ${err instanceof Error ? err.message : err}`); + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index b80aed1e..9b9bd491 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -39,6 +39,7 @@ import { emit, on } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { commitStatusTool } from "./delivery.js"; +import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, @@ -90,32 +91,17 @@ async function pollRepos(client: GiteaClient): Promise { // everything it sees then predates the watching. import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null; +// What has been announced is no longer kept in a file (novox/hq ADR 0406 §6, §7, issue 976): an announcement carries a +// message id (announce.ts), so the same merge announced by the poll and by the forge's event, or again after a restart, +// is stored once by the bus — within its de-duplication window, and past it the consumers' idempotence per merge commit +// holds. This process remembers what it announced meanwhile, so it does not ask the forge for the same files twice. +// The one value kept is where the poll last looked from (`since`), in the module's own state: a merge made while this +// module was away, after it, is announced when it is back; one before it is history. With none kept — the first look +// ever — nothing is announced, because everything it sees then predates the watching. const announced = new Set(); let primedMerges = false; -// since is the moment the watching began: a merge made before it is history, whatever page of the -// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view -// as newer pull requests were updated — was announced as if it had just happened, and the mesh -// rebuilt everything built from that repository, once per old merge (2026-09-28). let since = ""; -if (mergedRecord && existsSync(mergedRecord)) { - try { - const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string }; - const list = Array.isArray(kept) ? kept : kept.announced; - for (const sha of list) announced.add(sha); - since = Array.isArray(kept) ? new Date().toISOString() : kept.since; - primedMerges = true; - } catch { - // An unreadable record is treated as no record: prime again rather than re-announce history. - } -} -function keepAnnounced(): void { - if (!mergedRecord) return; - mkdirSync(join(mergedRecord, ".."), { recursive: true }); - const tmp = mergedRecord + ".tmp"; - writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since })); - renameSync(tmp, mergedRecord); -} +let watermarkRead = false; // **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the // repository's update time; asking every repository for its pull requests on every tick took longer than the // tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a @@ -123,10 +109,14 @@ function keepAnnounced(): void { let lastLook = ""; const MARGIN_MS = 60_000; async function pollMerged(client: GiteaClient): Promise { + if (!watermarkRead) { + since = await readWatermark(); + primedMerges = since !== ""; + watermarkRead = true; + } const began = new Date().toISOString(); const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : ""; const repos = movedSince(await client.listAllRepos(), floor); - let changed = false; for (const repo of repos) { const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" }); for (const pull of pulls) { @@ -134,13 +124,12 @@ async function pollMerged(client: GiteaClient): Promise { const closedKey = `closed:${repo.full_name}#${pull.number}`; if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) { if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) { - await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title, + await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title, head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url }); console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`); await siblingMoves(client, repo.full_name, pull.number); } announced.add(closedKey); - changed = true; continue; } if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue; @@ -152,11 +141,11 @@ async function pollMerged(client: GiteaClient): Promise { } else announced.add(pull.merge_commit_sha); announced.add(pull.merge_commit_sha); - changed = true; } } - if (!primedMerges) since = new Date().toISOString(); - if (!primedMerges || changed) keepAnnounced(); + // The next look starts from this one's start less the margin; kept, so a restart looks from there. + since = primedMerges ? new Date(Date.parse(began) - MARGIN_MS).toISOString() : began; + await keepWatermark(since); primedMerges = true; lastLook = began; } @@ -196,7 +185,7 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`); } } - await emit("pull.merged", { + await announce("pull.merged", { owner: repo.owner, repo: repo.name, number: pull.number, @@ -226,32 +215,17 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P announced.delete(pull.merge_commit_sha); throw err; } - keepAnnounced(); return true; } -// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it -// against every machine of its facts before it merges. Kept beside the merges' record, so a restart -// announces nothing twice; the first look on a machine with no record announces only what moved in the -// last day, so a forge's whole backlog is not checked at once. -const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null; +// **Every new head of an open pull request is announced** (novox/hq to-be 45 §9): the mesh checks it against every +// machine of its facts before it merges. What this process announced is remembered in memory only (novox/hq ADR 0406 +// §6, §7, issue 976): the announcement carries a message id (announce.ts), so the poll's, the forge event's and the +// merge tool's are one to the bus. After a start, the first look announces a head only if the forge shows no merge check +// of it yet — a head with one was announced — and only what moved in the last day, so a forge's whole backlog is not +// checked at once and a restart asks no check again against a trunk that moved. let heads: Announced = {}; let primedPulls = false; -if (pullsRecord && existsSync(pullsRecord)) { - try { - heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {}; - primedPulls = true; - } catch { - // An unreadable record is no record: the first look announces only the last day's. - } -} -function keepHeads(): void { - if (!pullsRecord) return; - mkdirSync(join(pullsRecord, ".."), { recursive: true }); - const tmp = pullsRecord + ".tmp"; - writeFileSync(tmp, JSON.stringify({ heads })); - renameSync(tmp, pullsRecord); -} /** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise { const key = `${repo.full_name}#${pull.number}`; @@ -261,13 +235,12 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr if (!claimHead(heads, key, sha)) return false; try { // The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5). - await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull)); + await announce("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull)); } catch (err) { if (before === undefined) delete heads[key]; else heads[key] = before; throw err; } - keepHeads(); // A sibling's new head puts out of date every verdict that read the sibling at another head (ADR 0382 §5). await siblingMoves(client, repo.full_name, pull.number); // Said, so an operator knows the mesh was asked to check it. @@ -279,6 +252,16 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr } type OpenOf = Awaited>[number]; +/** Whether the forge shows a merge check of the head, in any state: only an announced head has one. */ +async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise { + try { + const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha)); + return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks; + } catch { + return false; // the forge cannot say: announced, and the bus's message id collapses it if it was + } +} + let lastPullLook = ""; // **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a // heuristic, and a heuristic that misses is caught by the next sweep rather than never. @@ -288,7 +271,6 @@ async function pollPulls(client: GiteaClient): Promise { const began = new Date().toISOString(); const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : ""; const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString(); - let changed = false; // A pull request opened, reopened or retargeted moves its own update time and not its repository's: the // pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep. let sweep = Date.now() - lastSweep >= SWEEP_MS; @@ -309,14 +291,17 @@ async function pollPulls(client: GiteaClient): Promise { for (const pull of headsToAnnounce(repo.full_name, open, heads)) { const key = `${repo.full_name}#${pull.number}`; const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo); + // The first look after a start remembers nothing: a head the forge already shows a merge check of was announced. + if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) { + heads[key] = String(pull.head_sha); + continue; + } if (fresh) { if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`); } else heads[key] = String(pull.head_sha); - changed = true; } if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true; } - if (!primedPulls || changed) keepHeads(); if (changedTrunks) keepTrunks(); changedTrunks = false; primedPulls = true; diff --git a/modules/gitea/internal/busdedup/dedup_test.go b/modules/gitea/internal/busdedup/dedup_test.go new file mode 100644 index 00000000..b62b949b --- /dev/null +++ b/modules/gitea/internal/busdedup/dedup_test.go @@ -0,0 +1,82 @@ +package busdedup + +import ( + "context" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// **The same announcement published twice with one message id is stored once** (novox/hq ADR 0406 §6, issue 976). The +// forge module announces a merge, a head and a close with a deterministic `x-event-id` (announce.ts); the runtime makes +// that header the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the events stream's +// message de-duplication keeps the second. The publish here is the runtime's own: the headers the module stamps, the id +// as the message id. A real JetStream server is linked into the test and started for it alone (the TypeScript harness +// has none, so this is the Go half of the module's tests). + +const subject = "mesh.mod.gitea.event.pull.merged" + +func publish(t *testing.T, js nats.JetStreamContext, id string) *nats.PubAck { + t.Helper() + msg := nats.NewMsg(subject) + msg.Header.Set("x-event-id", id) + msg.Header.Set("x-source", "gitea") + msg.Header.Set("content-type", "application/json") + msg.Data = []byte(`{"owner":"novox","repo":"app","number":7,"merge_commit_sha":"mmm222"}`) + ack, err := js.PublishMsg(msg, nats.MsgId(id)) + if err != nil { + t.Fatal(err) + } + return ack +} + +func TestTheSameAnnouncementPublishedTwiceWithOneMessageIdIsStoredOnce(t *testing.T) { + s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, + StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + t.Fatal("a bus for this test did not come up within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := nc.JetStream() + if err != nil { + t.Fatal(err) + } + // The events stream as the controller makes it: two minutes of message de-duplication. + if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.>"}, Duplicates: 2 * time.Minute}); err != nil { + t.Fatal(err) + } + const id = "merged:novox/app#7@mmm222" + first := publish(t, js, id) + second := publish(t, js, id) // the poll and the forge's event both announced it + if first.Duplicate || !second.Duplicate { + t.Fatalf("the first publish was a duplicate: %v; the second: %v (wanted false, true)", first.Duplicate, second.Duplicate) + } + publish(t, js, "merged:novox/app#8@nnn333") // another merge is another message + jsNew, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + stream, err := jsNew.Stream(context.Background(), "EVENTS") + if err != nil { + t.Fatal(err) + } + info, err := stream.Info(context.Background()) + if err != nil { + t.Fatal(err) + } + if info.State.Msgs != 2 { + t.Fatalf("the stream stores %d messages for two announcements published three times; wanted 2", info.State.Msgs) + } +} diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index fc70b6ef..9b307b50 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -5,7 +5,7 @@ // puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. // Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. -import { emit } from "@novox/mesh-sdk/events"; +import { announce } from "./announce.js"; import type { ToolDefinition } from "@novox/mesh-sdk/tools"; import type { CommitStatus, GiteaClient } from "./client.js"; import { mergeableUnder, newestByContext } from "./checks.js"; @@ -27,7 +27,7 @@ export interface MergeDeps { } export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition { - const deps = { ...given, emit: given.emit ?? emit, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, + const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, now: given.now ?? (() => new Date()) }; return { name: "gitea_merge_pull_request", diff --git a/modules/gitea/module.json b/modules/gitea/module.json index ce0dbf71..324684eb 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -498,7 +498,8 @@ } ], "state": [ - "bound" + "bound", + "announced" ], "reads": [ "mesh-controller.assignments", diff --git a/modules/gitea/test/announce.test.ts b/modules/gitea/test/announce.test.ts new file mode 100644 index 00000000..222fee21 --- /dev/null +++ b/modules/gitea/test/announce.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// **The forge's announcements carry a message id** (novox/hq ADR 0406 §6, §7, issue 976): the same announcement, +// however many paths make it — the poll, the forge's event, the merge tool's ask — has one id, which the runtime makes +// the bus's message id, so the bus stores it once. This replaces the files of what was announced. + +const merged = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", merge_commit_sha: "mmm222", base: "main" }; +const updated = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", base_sha: "ttt333", base: "main" }; +const closed = { owner: "novox", repo: "app", number: 7, head_sha: "aaa111", base: "main" }; + +test("the same merge, head or close announced twice has the same id, and another has another", async () => { + const { announcementId } = await import("../announce.ts"); + assert.equal(announcementId("pull.merged", merged), "merged:novox/app#7@mmm222"); + assert.equal(announcementId("pull.merged", { ...merged, title: "worded differently" }), announcementId("pull.merged", merged)); + assert.notEqual(announcementId("pull.merged", { ...merged, merge_commit_sha: "other" }), announcementId("pull.merged", merged)); + assert.equal(announcementId("pull.updated", updated), "updated:novox/app#7@aaa111:ttt333"); + assert.notEqual(announcementId("pull.updated", { ...updated, base_sha: "moved" }), announcementId("pull.updated", updated), + "the check asked again against a new trunk commit is another announcement"); + assert.notEqual(announcementId("pull.updated", { ...updated, head_sha: "bbb" }), announcementId("pull.updated", updated)); + assert.equal(announcementId("pull.updated", { ...updated, base_sha: undefined }), "updated:novox/app#7@aaa111:"); + assert.equal(announcementId("pull.closed", closed), "closed:novox/app#7@aaa111"); +}); + +test("an announcement is published through the broker with its id as the event id, and the provenance emit stamps", async () => { + const { useBroker } = await import("@novox/mesh-sdk/messaging"); + const published: any[] = []; + useBroker(() => ({ publish: async (env: any) => { published.push(env); } }) as any); + process.env.MESH_MODULE = "gitea"; + process.env.MESH_NODE = "anchor"; + const { announce } = await import("../announce.ts"); + await announce("pull.merged", merged); + await announce("pull.merged", { ...merged }); + assert.equal(published.length, 2); + assert.equal(published[0].key, "pull.merged"); + assert.equal(published[0].headers["x-event-id"], "merged:novox/app#7@mmm222"); + assert.equal(published[1].headers["x-event-id"], published[0].headers["x-event-id"], "one id for one announcement"); + assert.equal(published[0].headers["x-source"], "gitea"); + assert.equal(published[0].headers["x-node"], "anchor"); + assert.equal(published[0].headers["content-type"], "application/json"); + assert.ok(published[0].headers["x-time"]); + assert.equal(published[0].node, "anchor"); + assert.deepEqual(published[0].body, merged); +}); + +test("an announcement of a kind it has no id for is refused, never published with a made-up one", async () => { + const { announce, announcementId } = await import("../announce.ts"); + assert.equal(announcementId("repo.created", { owner: "a" }), undefined); + await assert.rejects(() => announce("repo.created", { owner: "a" }), /not an announcement with a message id/); +}); + +test("the watermark is kept in the module's state, and an unreachable state is no record", async () => { + const { readWatermark, keepWatermark } = await import("../announce.ts"); + const kept = new Map(); + const store = { get: async (k: string) => (kept.has(k) ? { key: k, value: kept.get(k), revision: 1 } : null), + put: async (k: string, v: unknown) => { kept.set(k, v); return 1; } }; + assert.equal(await readWatermark(store as any), "", "none yet"); + await keepWatermark("2026-10-17T12:00:00.000Z", store as any); + assert.equal(await readWatermark(store as any), "2026-10-17T12:00:00.000Z"); + const away = { get: async () => { throw new Error("no runtime"); }, put: async () => { throw new Error("no runtime"); } }; + assert.equal(await readWatermark(away as any), "", "a state that cannot be reached is no record"); + await keepWatermark("2026-10-17T12:01:00.000Z", away as any); // said, not thrown +});