Work items, events and timers
From contract 8 an app's server code can hear about the workspace's own work items, read and change a few of their fields, and ask the platform to call it back later. This page covers subscriptions, gf.workitems, the scope an administrator grants, and timers, ending with the two handlers of Client Sign-off that use time.
What this is for
Apps can already declare their own models, processes and automations. With contract 8 their server code can also hear about and act on the workspace's own work items, and can ask for itself to be run later. Four things make that up:
- Subscriptions: the app says which events of work items, projects or work-item comments it wants to hear.
gf.workitems: server code reads and lists work items, and changes a few fields of them.- Scope: an administrator decides which items the app may reach at all.
- Timers: code arms a timer; when it is due the platform calls a handler of the app.
The larger real example is the Queues app (examples/apps/queues): tickets go into queues that say who gets them, how urgent they are and how long they may wait. Its server code hears every ticket change, fills in defaults, tells the queue, and arms escalation rules as timers. The small example is Client Sign-off's onChange and remind at the end of this page. Everything here is for the code path; what an administrator sees is on Installing and updating apps.
Subscriptions and events
A manifest asks to hear events of a platform entity with subscriptions. This needs requires.groupforce 8, and the app must also ask to read the entity in permissions.read (or write). This is the Queues manifest, verbatim:
"subscriptions": [
{
"entity": "work_item",
"events": [
"created",
"updated"
]
},
{
"entity": "work_item_comment",
"events": [
"created"
]
}
],
"server": {
"module": "server/index.js",
"hooks": [
{
"on": "work_item.created",
"handler": "onTicket"
},
{
"on": "work_item.updated",
"handler": "onTicket"
},
{
"on": "work_item_comment.created",
"handler": "onComment"
}
],
"timers": [
"escalate"
]
},
| Entity | Events | Notes |
|---|---|---|
work_item |
created, updated, deleted |
Needs work_item in permissions.read or write. |
project |
created, updated, deleted |
Needs project in permissions. The app is told only what the event says: id and title. |
work_item_comment |
created, updated, deleted |
The comments of a work item's conversation, public ones only (never an internal comment, never chat in a channel). No permission of its own: it is heard under the work item, so work_item must be in permissions.read. |
A hook is then named after the event: work_item.updated, project.created, work_item_comment.created. A hook for an event the app did not subscribe to is refused when the package is built with gfpkg build, and so is a subscription to a second copy of the same entity, an entity that cannot be subscribed to, or an event other than created, updated or deleted.
How delivery works
- Exactly once. Delivery can repeat. groupforce claims each change by app, record, event and version before running anything, so a second delivery of the same change runs nothing.
- The app never hears of its own writes. An event whose actor is the app itself (
app:<app id>) is skipped for that app, so a handler that changes a work item cannot set itself off again. Another app may hear it. - Only enabled, installed apps. A switched-off app and a preview slot receive nothing. A preview receives no live events.
- Only inside the scope. For a work item the platform asks for the item as the app, with the app's scope: found means the app may hear of it, not found means it may not (a change that took the item out of the scope is not delivered either). A deleted item can no longer be asked for, so only an app granted every item hears of its deletion. With no scope granted, the app hears nothing.
- No retry. The outcome (delivered, out of scope, or failed with a reason) is recorded; a hook that fails is not tried again.
What the handler receives is on Server code & sandboxing: {event, record, diff, version, actor}. diff has an entry per changed field with from and to; for custom fields that is diff.custom_fields, whose from and to are the maps of custom fields before and after. Queues reads it like this to undo a forged approval:
function guardApproval(input, gf, wi, queue) {
var d = input.diff && input.diff.custom_fields
var created = input.event === 'work_item.created'
var actor = input.actor && input.actor.id
if (!created && !(d && d.to && Object.prototype.hasOwnProperty.call(d.to, K.approval))) return false
var to = (wi.custom_fields || {})[K.approval]
var from = created ? null : (d.from || {})[K.approval]
if (approvalAllowed(gf, wi, from, to, actor, queue)) return false
var cf = {}
cf[K.approval] = from == null ? null : from
gf.workitems.update(wi.id, { custom_fields: cf })
if (actor) tell(gf, [actor], 'Not allowed: ' + ref(wi), 'Only the ticket\'s reporter and assignees ask for approval (not again once it is approved), and only the approval user of ' + queue.title + ' decides, on an open request; the change was undone.', wi)
return true
}
gf.workitems
Three calls, all as the app and all checked twice, once when the call is made and again when the work item is read or changed:
| Call | What it does |
|---|---|
gf.workitems.get(id) |
One work item, as groupforce describes it. An item outside the scope is "not_found", as for a person who may not see it. |
gf.workitems.list({cf, status, assignee_id, project_id, type, sort, limit, offset}) |
Items inside the scope. Answers {items, total}; limit is 50 by default and may not exceed 200 (a larger value is replaced by 50). cf is the custom-field filter: an object of key: value pairs, all of which must hold, at most 10; a text value matches text, a number matches a number, true/false a boolean. sort is cf.<key> or -cf.<key> only (work items sort by a custom field here, nothing else). |
gf.workitems.update(id, patch, {expected_version}) |
Changes the item with a field merge: what the patch does not name stays as it is. With expected_version the change is made only if the item is still at that version. |
What an app may change
status,priority,assigneesanddue_at.project_id, for an item that has no project yet, once, and only a project inside the granted scope. An item that has a project keeps it.custom_fields, but only keys that start with the app's own id followed by a dot (<app id>.<name>). An app whose id iscom.groupforce.queueswritescom.groupforce.queues.queue_id; it may not writecom.other.app.k, an unprefixed key or a key that belongs to the import. The answer is "forbidden", naming the key. That is how two apps never overwrite each other's data.- Not the title, description, start date or tags: those are a person's. Anything else is refused before any work item is read or changed.
Queues names its keys once, under its own id:
var P = 'com.groupforce.queues.'
var K = {
queue: P + 'queue_id', est: P + 'estimate_minutes', cost: P + 'cost', cur: P + 'cost_currency',
init: P + 'init', news: P + 'new_notified', approval: P + 'approval', approvalSeen: P + 'approval_notified', escalated: P + 'escalated',
}
and writes them with one call, here undoing a forged approval:
var cf = {}
cf[K.approval] = from == null ? null : from
gf.workitems.update(wi.id, { custom_fields: cf })
The manifest can give such keys readable names for the work item's history with custom_fields (a label and how the value reads; contract 8), so people read "set Queue to Support" and not the key.
Conflicts and limits
- A person's edit made after
expected_versiongives 412, which the code sees as an exception whosecodeis"conflict". The host does not retry; the handler decides, usually by reading the item again and looking at the present state. (Queues does not passexpected_version; it merges only keys it owns.) - Other refusals reach the code as exceptions with
code"forbidden","not_found","invalid"or"failed". A handler that expects one catches it. - At most 50 changes to work items in one run ("limit"). Each change is logged with the app, the handler and the item.
- The app must be switched on to change an item.
- An app's own keys are not locked against people. Anyone who may edit the item can write them; code that depends on a value not being forged has to check it. Queues does exactly that and writes a forged value back.
Scope: the administrator's choice
Declaring work_item or project in permissions is only a request. The administrator chooses, per entity and per right (read, write): nothing (the default), only the items of chosen projects (optionally with the items that belong to no project), or every item. A write grant needs write in the manifest; a read grant needs read or write.
gf.workitems.get and list use the read scope (a write grant also reads), update only the write scope. With nothing granted the call is refused with a message that says an administrator has not allowed the app to read or change work items. The scope travels with each request and is enforced where work items are read and changed, so it cannot be widened from inside the app. An uninstalled app loses its grants; a reinstalled one starts with nothing.
The choice is made in Workspace settings, Apps, the app's menu, What it may reach, and the install dialog tells the administrator that the app reaches none of them until they choose. Installing and updating apps shows where. In App Studio an app that asks for work items and has been allowed none is marked Needs attention.
Timers
gf.timers.set(key, after, handler, payload) asks the platform to call one of the app's handlers later. The handler must be named in server.timers (up to 20 names, contract 8), so an administrator can read what an app is able to set going.
| Call | What it does |
|---|---|
gf.timers.set(key, after, handler, payload) |
Arms the timer and answers {due_at}. after is a number of minutes or text such as 30m, 2h, 3d, 1h30m, between 1 minute and 90 days. payload is JSON of at most 4 KiB. key is up to 200 characters of letters, digits and _ . : / -. |
gf.timers.cancel(key) |
Removes it; answers true if there was one. |
gf.timers.get(key) |
Answers {due_at, handler} if the key is waiting, otherwise nothing. |
- One timer per (app, key). Setting a key again moves it; a handler may re-arm its own key while it runs. At most 500 timers are kept per app, failed ones included.
- The scheduler runs every 20 seconds in every workspace. Due timers are claimed so that any number of replicas share the work and none runs a timer twice; a claim stuck for five minutes (a crashed replica) is taken again. A switched-off app's timers wait.
- The handler is called with
{key, payload, due_at, fired_at}, as the app itself, with the same limits as any handler. - If the handler fails it is tried again: after the first failure it waits one minute, after the second two minutes, and a third failure leaves the timer as
failed. Setting the key again starts it afresh.
Decide from the present state. A timer is a reminder to look again, not a promise that something is still true. The handler re-reads the record and checks that what armed it still holds. Nothing about "still true" is kept by the platform, so a timer never acts on a stale picture.
Working hours
A timer counts wall-clock time. To wait for working time, work out how many minutes that is with gf.hours.add(start, minutes, {zone, days, from, to}) and arm the timer with the result. It is computed on the server with real time zones, so a working day that starts at 09:00 in Berlin is right in summer and winter. It looks at most 800 days ahead, and minutes may be at most a year of minutes. A zone it does not know is refused. The Queues app does it like this:
function waitFor(gf, rule, queue, nowMs, after) {
var cal = rule.data.business_hours ? calendarOf(queue) : null
if (!cal) return after
var at = safe(function () { return gf.hours.add(new Date(nowMs).toISOString(), after, cal) }, null)
return at ? Math.max(1, Math.ceil((Date.parse(at) - nowMs) / 60000)) : after
}
and arms an escalation rule with the answer: gf.timers.set(key, p.wait, 'escalate', { rule: rule.id, item: wi.id }).
The example: onChange and remind
Client Sign-off wants a deliverable that has waited two days with the client to remind its reviewer. The process cannot say that, so the app does it in two handlers. onChange is the hook on model.deliverable.updated; it arms a timer when a deliverable is in Client review and cancels it when it is not. remind is the timer handler, declared as server.timers: ["remind"].
function onChange(input, gf) {
var rec = input.record
if (!rec) return
var key = 'remind:' + rec.id
if (rec.data.status === WAITING) {
gf.timers.set(key, '2d', 'remind', { id: rec.id })
} else {
gf.timers.cancel(key)
}
}
function remind(input, gf) {
var rec = gf.records.get('deliverable', input.payload.id)
if (!rec || rec.data.status !== WAITING || !rec.data.reviewer) return
gf.notify([rec.data.reviewer], 'Still waiting for the client: ' + rec.data.title,
'It has been in client review for two days. Ask the client, or move it on.')
}
Read what the timer does not carry: only { id: rec.id }. When it fires, remind reads the deliverable again and does nothing unless it still waits for the client and has a reviewer. Because there is one timer per key (remind:<id>), every later change of the deliverable moves the same timer and does not add another.
At a glance
| You want to | How |
|---|---|
| Hear when a work item, project or comment changes | subscriptions and a hook named work_item.updated and so on, once an administrator grants a scope. |
| React without writing code | An automation with on: {entity: "work_item" | "work_item_comment", event, ...} (for a comment the event is created or deleted) may notify, set_work_item (priority, status, due date, assignees, the app's own custom fields) or call a handler. It needs the same subscription and scope. App Studio lists such automations; you declare them in the manifest. |
| Read and list work items | gf.workitems.get and list, inside the scope, filtered by custom field. |
| Change a work item | gf.workitems.update: status, priority, assignees, due date, the project of an item that has none, and the app's own custom-field keys. |
| Run something at a given time | gf.timers.set, from a minute to 90 days ahead, declared in server.timers. To repeat, re-arm the timer from its handler. |
| Count only working hours | Work out the wait with gf.hours.add and arm the timer with it. |
The Queues app is the larger example to read: examples/apps/queues/app.json and server/index.js use subscriptions, gf.workitems, timers, gf.hours.add, three extension points (see Extension points) and custom_fields.