anbaric-state-machine 1.18.1 → 1.20.0
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.
- package/package.json +3 -3
- package/src/StateMachine.ts +81 -75
- package/src/actors/Code.ts +3 -3
- package/src/actors/Human.ts +18 -6
- package/src/index.ts +3 -0
- package/src/persistence/InMemoryJobPersistence.ts +0 -0
- package/src/sessions/InMemorySessionResolver.ts +20 -0
- package/src/sessions/SessionResolverFactory.ts +16 -0
- package/src/sessions/sessionCookie.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-state-machine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "This is the state machine library that one can use to add state management to an application. It will, by default, be locally runnable but can deployed to the Anbaric Cloud via the CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chris@anbaric.ai",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"typescript": "^7.0.2"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"anbaric-impl-cloud": "^1.
|
|
19
|
-
"anbaric-tsapi": "^1.
|
|
18
|
+
"anbaric-impl-cloud": "^1.20.0",
|
|
19
|
+
"anbaric-tsapi": "^1.20.0"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"src"
|
package/src/StateMachine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Action,
|
|
3
3
|
Actor,
|
|
4
4
|
Auditor,
|
|
5
|
+
Await,
|
|
5
6
|
Consumer,
|
|
6
7
|
Job,
|
|
7
8
|
JobPersistence,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
SystemActor,
|
|
12
13
|
WorkflowDefinition
|
|
13
14
|
} from "anbaric-tsapi";
|
|
15
|
+
|
|
14
16
|
import {JobPersistenceFactory} from "./persistence/JobPersistenceFactory";
|
|
15
17
|
import {QueueFactory} from "./scheduling/QueueFactory";
|
|
16
18
|
import {ConsumerFactory} from "./scheduling/ConsumerFactory";
|
|
@@ -33,9 +35,6 @@ class StateMachine {
|
|
|
33
35
|
|
|
34
36
|
constructor(workflowId : string, states : Array<State>, startState? : string, dataSchema : Array<PropertyDefinition> = [], persistence : JobPersistence = JobPersistenceFactory.instance(), queue : Queue = QueueFactory.instance(), sameStateDelayMs : number = 5 * 60_000, auditor : Auditor = AuditorFactory.instance()) {
|
|
35
37
|
|
|
36
|
-
// A deployed app namespaces its state machines by app id, so the same
|
|
37
|
-
// workflow id used in two different apps never collides. Run locally
|
|
38
|
-
// (no app id in the environment) the id is used as given.
|
|
39
38
|
const appId = process.env.ANBARIC_APP_ID;
|
|
40
39
|
this.workflowId = appId ? `${appId}/${workflowId}` : workflowId;
|
|
41
40
|
this.states = new Map(states.map(state => [state.id, state]));
|
|
@@ -55,21 +54,8 @@ class StateMachine {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
private describe() : WorkflowDefinition {
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
startState: this.startState,
|
|
61
|
-
dataSchema: [...this.dataSchema.values()].map(property => ({ id: property.id, required: property.required })),
|
|
62
|
-
states: [...this.states.values()].map(state => ({
|
|
63
|
-
id: state.id,
|
|
64
|
-
isTerminal: state.isTerminal,
|
|
65
|
-
actions: state.actions.map(action => ({
|
|
66
|
-
name: action.name,
|
|
67
|
-
description: action.description,
|
|
68
|
-
actor: { id: action.actor.id, type: action.actor.type, role: action.actor.role },
|
|
69
|
-
})),
|
|
70
|
-
transitions: state.transitions.map(transition => ({ to: transition.to })),
|
|
71
|
-
})),
|
|
72
|
-
};
|
|
57
|
+
return WorkflowDefinition.describe(this.workflowId, this.startState,
|
|
58
|
+
[...this.states.values()], [...this.dataSchema.values()]);
|
|
73
59
|
}
|
|
74
60
|
|
|
75
61
|
async startJob(properties?: Map<string, any>, actor : Actor = this.machineActor): Promise<Job> {
|
|
@@ -111,6 +97,83 @@ class StateMachine {
|
|
|
111
97
|
}
|
|
112
98
|
}
|
|
113
99
|
|
|
100
|
+
private async progressJob(jobId : string) : Promise<void> {
|
|
101
|
+
|
|
102
|
+
const job = await this.persistence.retrieve(jobId, this.machineActor);
|
|
103
|
+
if (job.killed) return;
|
|
104
|
+
|
|
105
|
+
const currentState = this.states.get(job.state);
|
|
106
|
+
if (currentState?.isTerminal) return;
|
|
107
|
+
|
|
108
|
+
// A job resuming from "Awaiting input" (re-enqueued by an update to its
|
|
109
|
+
// properties) skips its actions entirely and only re-evaluates its
|
|
110
|
+
// transitions - the input it was parked for drives it on.
|
|
111
|
+
const wasAwaiting = job.status === Job.Status.AWAITING_INPUT;
|
|
112
|
+
const involvedActors : Array<Actor> = [];
|
|
113
|
+
let propertiesChanged = false;
|
|
114
|
+
|
|
115
|
+
if (! wasAwaiting) {
|
|
116
|
+
for (const item of currentState?.actions ?? []) {
|
|
117
|
+
if (item instanceof Await) {
|
|
118
|
+
job.status = Job.Status.AWAITING_INPUT;
|
|
119
|
+
job.awaitMetadata = item.waitForInput(job);
|
|
120
|
+
job.waitingFor = crypto.randomUUID();
|
|
121
|
+
await this.persistence.save(this.machineActor, `Job ${job.id} awaiting input`, job,
|
|
122
|
+
propertiesChanged ? job.properties : undefined);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (! item.predicate(job)) continue;
|
|
127
|
+
if (! this.authorizeActor(item.actor, job)) continue;
|
|
128
|
+
|
|
129
|
+
const newProperties = await item.run(job);
|
|
130
|
+
|
|
131
|
+
let applied = false;
|
|
132
|
+
for (const [key, value] of newProperties) {
|
|
133
|
+
const problem = this.propertyProblem(key, value);
|
|
134
|
+
if (problem) {
|
|
135
|
+
console.warn(`[${this.workflowId}] action "${item.name}" set "${key}", which ${problem}, on job ${job.id} — skipping that property.`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (job.properties.has(key) && this.sameValue(job.properties.get(key), value)) continue;
|
|
139
|
+
job.properties.set(key, value);
|
|
140
|
+
propertiesChanged = true;
|
|
141
|
+
applied = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (applied) involvedActors.push(item.actor);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let newState : string | undefined = undefined;
|
|
149
|
+
for (const transition of currentState?.transitions ?? []) {
|
|
150
|
+
if (! this.states.has(transition.to)) continue;
|
|
151
|
+
if (transition.predicate(job)) {
|
|
152
|
+
newState = transition.to;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// TODO: persistence.save should have the ability to accept multiple actors for this case.
|
|
158
|
+
if (newState !== undefined) {
|
|
159
|
+
job.status = Job.Status.ACTIVE;
|
|
160
|
+
job.awaitMetadata = undefined;
|
|
161
|
+
job.waitingFor = undefined;
|
|
162
|
+
await this.persistence.save(involvedActors[0] ?? this.machineActor, `Job ${job.id} progressed automatically`,
|
|
163
|
+
job, propertiesChanged ? job.properties : undefined, newState);
|
|
164
|
+
if (! this.states.get(newState)?.isTerminal) await this.queue.enqueue(job.id, this.workflowId);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Parked (still awaiting) or nothing changed: leave the job be until the
|
|
169
|
+
// next update. Otherwise re-check this state after the back-off delay.
|
|
170
|
+
if (wasAwaiting || ! propertiesChanged) return;
|
|
171
|
+
|
|
172
|
+
await this.persistence.save(involvedActors[0] ?? this.machineActor, `Job ${job.id} progressed automatically`,
|
|
173
|
+
job, job.properties);
|
|
174
|
+
await this.queue.schedule(job.id, this.workflowId, new Date(Date.now() + this.sameStateDelayMs));
|
|
175
|
+
}
|
|
176
|
+
|
|
114
177
|
private async updateJobInternal(actor: Actor, message : string, job: Job, newProperties?: Map<string, any>, newState? : string) {
|
|
115
178
|
|
|
116
179
|
if (newProperties && !this.validateProperties(newProperties, false)) throw new Error("Invalid properties");
|
|
@@ -140,63 +203,6 @@ class StateMachine {
|
|
|
140
203
|
return undefined;
|
|
141
204
|
}
|
|
142
205
|
|
|
143
|
-
private async progressJob(jobId : string) : Promise<void> {
|
|
144
|
-
let pristine = true;
|
|
145
|
-
|
|
146
|
-
const job = await this.persistence.retrieve(jobId, this.machineActor);
|
|
147
|
-
if (job.killed) return;
|
|
148
|
-
|
|
149
|
-
const currentState = this.states.get(job.state);
|
|
150
|
-
|
|
151
|
-
if (currentState?.isTerminal) return;
|
|
152
|
-
|
|
153
|
-
const involvedActors : Actor[] = [];
|
|
154
|
-
|
|
155
|
-
for (const action of currentState?.actions ?? []) {
|
|
156
|
-
if (! action.predicate(job)) continue;
|
|
157
|
-
if (! this.authorizeActor(action.actor, job)) continue;
|
|
158
|
-
|
|
159
|
-
const newProperties = await action.run(job);
|
|
160
|
-
|
|
161
|
-
let applied = false;
|
|
162
|
-
for (const [key, value] of newProperties) {
|
|
163
|
-
const problem = this.propertyProblem(key, value);
|
|
164
|
-
if (problem) {
|
|
165
|
-
console.warn(`[${this.workflowId}] action "${action.name}" set "${key}", which ${problem}, on job ${job.id} — skipping that property.`);
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (job.properties.has(key) && this.sameValue(job.properties.get(key), value)) continue;
|
|
169
|
-
job.properties.set(key, value);
|
|
170
|
-
pristine = false;
|
|
171
|
-
applied = true;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (applied) involvedActors.push(action.actor);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
let newState : string | undefined = undefined;
|
|
178
|
-
for (const transition of currentState?.transitions ?? []) {
|
|
179
|
-
if (! this.states.has(transition.to)) continue;
|
|
180
|
-
if (transition.predicate(job)) {
|
|
181
|
-
newState = transition.to;
|
|
182
|
-
pristine = false;
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (pristine) return;
|
|
188
|
-
|
|
189
|
-
// TODO: persistence.save should have the ability to accept multiple actors for this case.
|
|
190
|
-
await this.persistence.save(involvedActors[0] ?? this.machineActor, `Job ${job.id} progressed automatically`, job, job.properties, newState);
|
|
191
|
-
|
|
192
|
-
if (this.states.get(newState ?? job.state)?.isTerminal) return;
|
|
193
|
-
if (newState !== undefined) {
|
|
194
|
-
await this.queue.enqueue(job.id, this.workflowId);
|
|
195
|
-
} else {
|
|
196
|
-
await this.queue.schedule(job.id, this.workflowId, new Date(Date.now() + this.sameStateDelayMs));
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
206
|
private sameValue(a : any, b : any) : boolean {
|
|
201
207
|
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
202
208
|
}
|
package/src/actors/Code.ts
CHANGED
|
@@ -4,11 +4,11 @@ class Code implements Actor {
|
|
|
4
4
|
|
|
5
5
|
readonly type = "CODE" as const;
|
|
6
6
|
readonly id : string;
|
|
7
|
-
readonly
|
|
7
|
+
readonly roles : Array<string>;
|
|
8
8
|
|
|
9
|
-
constructor(id : string,
|
|
9
|
+
constructor(id : string, roles : Array<string> | string = "code") {
|
|
10
10
|
this.id = id;
|
|
11
|
-
this.
|
|
11
|
+
this.roles = typeof roles === "string" ? [roles] : roles;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
}
|
package/src/actors/Human.ts
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {IncomingMessage} from "node:http";
|
|
2
|
+
import {Actor, SessionResolver} from "anbaric-tsapi";
|
|
3
|
+
import {SessionResolverFactory} from "../sessions/SessionResolverFactory";
|
|
4
|
+
import {sessionCookie} from "../sessions/sessionCookie";
|
|
2
5
|
|
|
3
6
|
class Human implements Actor {
|
|
4
7
|
|
|
5
8
|
readonly type = "HUMAN" as const;
|
|
6
9
|
readonly id : string;
|
|
7
|
-
readonly
|
|
10
|
+
readonly roles : Array<string>;
|
|
8
11
|
|
|
9
|
-
constructor(id : string,
|
|
12
|
+
constructor(id : string, roles : Array<string> | string) {
|
|
10
13
|
this.id = id;
|
|
11
|
-
this.
|
|
14
|
+
this.roles = typeof roles === "string" ? [roles] : roles;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
/* Resolves the browser user behind a request to an app page into a Human,
|
|
18
|
+
for auditing. Takes the platform session - either the anbaric_session
|
|
19
|
+
token, or the incoming request to read the cookie from - and asks the
|
|
20
|
+
platform to verify it (an app cannot: the signing secret is platform
|
|
21
|
+
only). */
|
|
22
|
+
static async fromSession(source : string | IncomingMessage,
|
|
23
|
+
resolver : SessionResolver = SessionResolverFactory.instance()) : Promise<Human> {
|
|
24
|
+
const token = typeof source === "string" ? source : sessionCookie(source);
|
|
25
|
+
const session = token ? await resolver.resolve(token) : undefined;
|
|
26
|
+
if (!session) throw new Error("Could not resolve the session");
|
|
27
|
+
return new Human(session.id, session.roles);
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,6 @@ export * from "./scheduling/InMemoryQueue";
|
|
|
11
11
|
export * from "./scheduling/PullConsumer";
|
|
12
12
|
export * from "./scheduling/ConsumerFactory";
|
|
13
13
|
export * from "./scheduling/QueueFactory";
|
|
14
|
+
export * from "./sessions/InMemorySessionResolver";
|
|
15
|
+
export * from "./sessions/SessionResolverFactory";
|
|
16
|
+
export * from "./sessions/sessionCookie";
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {ResolvedSession, SessionResolver} from "anbaric-tsapi";
|
|
2
|
+
|
|
3
|
+
/* A resolver backed by a seeded token->session map, for local runs and tests.
|
|
4
|
+
Empty by default, so an unknown token resolves to undefined. */
|
|
5
|
+
class InMemorySessionResolver implements SessionResolver {
|
|
6
|
+
|
|
7
|
+
private sessions = new Map<string, ResolvedSession>();
|
|
8
|
+
|
|
9
|
+
seed(sessionToken : string, session : ResolvedSession) : this {
|
|
10
|
+
this.sessions.set(sessionToken, session);
|
|
11
|
+
return this;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async resolve(sessionToken : string) : Promise<ResolvedSession | undefined> {
|
|
15
|
+
return this.sessions.get(sessionToken);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { InMemorySessionResolver }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {SessionResolver} from "anbaric-tsapi";
|
|
2
|
+
import {CloudSessionResolver} from "anbaric-impl-cloud";
|
|
3
|
+
import {InMemorySessionResolver} from "./InMemorySessionResolver";
|
|
4
|
+
|
|
5
|
+
const SessionResolverFactory = {
|
|
6
|
+
instance() : SessionResolver {
|
|
7
|
+
switch (process.env.ANBARIC_SESSION_RESOLVER_TYPE) {
|
|
8
|
+
case "cloud":
|
|
9
|
+
return new CloudSessionResolver();
|
|
10
|
+
default:
|
|
11
|
+
return new InMemorySessionResolver();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { SessionResolverFactory };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {IncomingMessage} from "node:http";
|
|
2
|
+
|
|
3
|
+
const SESSION_COOKIE = "anbaric_session";
|
|
4
|
+
|
|
5
|
+
/* Reads the anbaric_session cookie value from an incoming request (or a raw
|
|
6
|
+
Cookie header string). Returns undefined when the cookie is absent. */
|
|
7
|
+
const sessionCookie = (source : IncomingMessage | string) : string | undefined => {
|
|
8
|
+
const header = typeof source === "string" ? source : String(source.headers.cookie ?? "");
|
|
9
|
+
for (const cookie of header.split(";")) {
|
|
10
|
+
const [name, ...value] = cookie.trim().split("=");
|
|
11
|
+
if (name === SESSION_COOKIE) return value.join("=");
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { sessionCookie, SESSION_COOKIE }
|