@fungi.computer/workflows-wire 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ import { workflowDefinitionSchema, workflowIdSchema, } from "@fungi.computer/braid";
2
+ import { countingGit, ReadCopyBudgetExceeded, } from "@fungi.computer/agar/read-copy";
3
+ import * as S from "effect/Schema";
4
+ import * as z from "zod";
5
+ import { provenanceSchema } from "./library-contracts.js";
6
+ /**
7
+ * The read side of a Workflows library's Git layout, shared by the Workflows
8
+ * App and the hosts that read its published definitions: one repository,
9
+ * `workflows`, one `workflows/<id>.json` file per Workflow (its definition
10
+ * plus `layout` and optional `provenance`), and one `<id>@v<n>` tag per
11
+ * published version on `main`'s ancestry.
12
+ */
13
+ export const workflowLibraryRepository = "trellis";
14
+ // Git's SHA-1 identity for an empty tree: no directory entry can be present.
15
+ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
16
+ /** Where one Workflow's file lives in its library repository. */
17
+ export const workflowFilePath = (id) => `workflows/${workflowIdSchema.parse(id)}.json`;
18
+ const layoutSchema = S.Record(S.String, S.Struct({ x: S.Finite, y: S.Finite }));
19
+ export const workflowDraftSchema = S.Struct({
20
+ definition: workflowDefinitionSchema,
21
+ layout: layoutSchema,
22
+ provenance: S.optionalKey(S.declare((value) => provenanceSchema.safeParse(value).success)),
23
+ });
24
+ /** Read the immediate Workflow directory without following symlinks or submodules. */
25
+ export async function workflowFiles(git, commit) {
26
+ if (!commit || /^0{40}$/.test(commit))
27
+ return [];
28
+ const metadata = await git.readCommit(workflowLibraryRepository, commit);
29
+ if (!metadata || metadata.treeHash === emptyTree)
30
+ return [];
31
+ const directory = (await git.readTree(workflowLibraryRepository, metadata.treeHash))?.find((entry) => entry.name === "workflows" && entry.type === "tree");
32
+ return directory
33
+ ? ((await git.readTree(workflowLibraryRepository, directory.hash)) ?? [])
34
+ : [];
35
+ }
36
+ /** Changed Workflow IDs in one accepted ref update, including deletions and mode changes. */
37
+ export async function changedWorkflowIds(git, before, after) {
38
+ const previous = new Map((await workflowFiles(git, before)).map((entry) => [
39
+ entry.name,
40
+ `${entry.mode}:${entry.hash}`,
41
+ ]));
42
+ const current = new Map((await workflowFiles(git, after)).map((entry) => [
43
+ entry.name,
44
+ `${entry.mode}:${entry.hash}`,
45
+ ]));
46
+ return [...new Set([...previous.keys(), ...current.keys()])]
47
+ .filter((name) => name.endsWith(".json") &&
48
+ workflowIdSchema.safeParse(name.slice(0, -5)).success &&
49
+ previous.get(name) !== current.get(name))
50
+ .map((name) => name.slice(0, -5));
51
+ }
52
+ /** Decode a pushed file separately from the admitted catalog's semantic validation. */
53
+ export async function inspectWorkflowFile(git, workflowId, commit) {
54
+ const name = workflowFilePath(workflowId).slice("workflows/".length);
55
+ const entry = (await workflowFiles(git, commit)).find((item) => item.name === name);
56
+ if (!entry)
57
+ return { kind: "missing" };
58
+ if (entry.mode !== "100644" && entry.mode !== "100755")
59
+ return {
60
+ kind: "invalid",
61
+ issues: [{ code: "schema", message: "Workflow must be a regular file" }],
62
+ };
63
+ const bytes = await git.readBlob(workflowLibraryRepository, entry.hash);
64
+ if (!bytes)
65
+ return {
66
+ kind: "invalid",
67
+ issues: [{ code: "schema", message: "Workflow blob is missing" }],
68
+ };
69
+ try {
70
+ const file = z
71
+ .record(z.string(), z.json())
72
+ .parse(JSON.parse(new TextDecoder().decode(bytes)));
73
+ const { layout = {}, provenance, ...definition } = file;
74
+ const content = { definition, layout };
75
+ const decode = S.decodeUnknownSync(workflowDraftSchema);
76
+ // A file without provenance stays without it; the key is optional.
77
+ const draft = Object.hasOwn(file, "provenance")
78
+ ? decode({ ...content, provenance }, { onExcessProperty: "error" })
79
+ : decode(content, { onExcessProperty: "error" });
80
+ return { kind: "valid", draft };
81
+ }
82
+ catch (cause) {
83
+ if (!(cause instanceof SyntaxError) &&
84
+ !(cause instanceof z.ZodError) &&
85
+ !S.isSchemaError(cause))
86
+ throw cause;
87
+ return {
88
+ kind: "invalid",
89
+ issues: [
90
+ {
91
+ code: "schema",
92
+ message: "Workflow definition or layout failed decoding",
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ }
98
+ /** One Workflow's file at `commit`, or null when it is absent or invalid. */
99
+ export async function readWorkflowAt(git, workflowId, commit) {
100
+ if (!commit)
101
+ return null;
102
+ const inspected = await inspectWorkflowFile(git, workflowId, commit);
103
+ return inspected.kind === "valid" ? { ...inspected.draft, commit } : null;
104
+ }
105
+ /** A Workflow's published versions, newest first, from its `<id>@v<n>` tags. */
106
+ export function versionRefs(workflowId, references) {
107
+ const prefix = `refs/tags/${workflowId}@v`;
108
+ return references
109
+ .flatMap((ref) => {
110
+ if (!ref.name.startsWith(prefix) ||
111
+ !/^[1-9][0-9]*$/.test(ref.name.slice(prefix.length)))
112
+ return [];
113
+ const version = Number(ref.name.slice(prefix.length));
114
+ return Number.isSafeInteger(version)
115
+ ? [{ version, commit: ref.hash }]
116
+ : [];
117
+ })
118
+ .sort((a, b) => b.version - a.version);
119
+ }
120
+ /** Batched first-parent reads retain every merge parent; the caller owns its IO budget. */
121
+ export async function workflowCommitIsAncestor(git, before, after) {
122
+ const pending = [after];
123
+ const seen = new Set();
124
+ while (pending.length && seen.size < 4096) {
125
+ const head = pending.pop();
126
+ if (!head || seen.has(head))
127
+ continue;
128
+ const limit = Math.min(1000, 4096 - seen.size);
129
+ const commits = await git.log(workflowLibraryRepository, {
130
+ ref: head,
131
+ limit,
132
+ });
133
+ // LIFO: defer the page continuation beneath this page's merge parents.
134
+ if (commits.length === limit) {
135
+ const parent = commits.at(-1)?.parents[0];
136
+ if (parent)
137
+ pending.push(parent);
138
+ }
139
+ // Newest merge parents go on top, matching the ordinary ancestry walk.
140
+ for (const commit of [...commits].reverse()) {
141
+ if (commit.hash === before)
142
+ return true;
143
+ if (seen.has(commit.hash))
144
+ continue;
145
+ seen.add(commit.hash);
146
+ pending.push(...commit.parents.slice(1));
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+ /** Each Git object read at most once while one listing runs. */
152
+ export function listingGit(git) {
153
+ const once = (load) => {
154
+ const pending = new Map();
155
+ return (name, hash) => {
156
+ const key = `${name}/${hash}`;
157
+ let value = pending.get(key);
158
+ if (!value) {
159
+ value = load(name, hash);
160
+ pending.set(key, value);
161
+ }
162
+ return value;
163
+ };
164
+ };
165
+ return {
166
+ readCommit: once((name, hash) => git.readCommit(name, hash)),
167
+ readTree: once((name, hash) => git.readTree(name, hash)),
168
+ readBlob: once((name, hash) => git.readBlob(name, hash)),
169
+ };
170
+ }
171
+ /**
172
+ * One published Workflow: a version tag, or a commit, on `main`'s ancestry.
173
+ * `copied` answers from a caller's own read copy of that commit when it has
174
+ * one; otherwise the file is read from Git within a bounded read budget.
175
+ */
176
+ export async function readPublishedWorkflow(git, workflowId, at, copied = () => null) {
177
+ workflowIdSchema.parse(workflowId);
178
+ const controller = new AbortController();
179
+ const current = countingGit(git, {
180
+ limit: 40,
181
+ signal: controller.signal,
182
+ }).git;
183
+ try {
184
+ if (!(await current.info(workflowLibraryRepository)))
185
+ return null;
186
+ const references = await current.refs(workflowLibraryRepository);
187
+ const main = references.find((ref) => ref.name === "refs/heads/main")?.hash;
188
+ if (!main)
189
+ return null;
190
+ const tagged = versionRefs(workflowId, references);
191
+ const version = "version" in at
192
+ ? tagged.find((item) => item.version === at.version)
193
+ : tagged.find((item) => item.commit === at.commit);
194
+ const commit = "commit" in at ? at.commit : version?.commit;
195
+ if (!commit || !(await workflowCommitIsAncestor(current, commit, main)))
196
+ return null;
197
+ // The live main ancestry admits publication; a read copy never does.
198
+ const document = copied(commit) ?? (await readWorkflowAt(current, workflowId, commit));
199
+ return document ? { ...document, version: version?.version ?? null } : null;
200
+ }
201
+ catch (cause) {
202
+ if (cause instanceof ReadCopyBudgetExceeded)
203
+ return null;
204
+ throw cause;
205
+ }
206
+ finally {
207
+ controller.abort();
208
+ }
209
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@fungi.computer/workflows-wire",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "author": "Levi <levi@technomancy.dev>",
5
+ "private": false,
6
+ "description": "Workflow run ids and generic graph wire contract shared by Hub and the Trellis App",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@fungi.computer/braid": "0.0.1",
16
+ "@fungi.computer/cairn": "0.0.1",
17
+ "@fungi.computer/puffball": "0.0.2",
18
+ "effect": "4.0.2",
19
+ "zod": "4.4.3",
20
+ "@fungi.computer/agar": "0.1.0"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/fungi-computer/workflows-wire.git"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ]
6
32
  }